From 917ad587c635aac55a97fc994fa939e6e0874a4c Mon Sep 17 00:00:00 2001 From: huyplb Date: Tue, 29 Sep 2026 09:09:56 -0600 Subject: [PATCH] feat(auth): require sign-in on every install Fox used to open straight into the workspace on a personal install, and a multi-user server let anyone register. Now every install signs in, and only an administrator creates accounts. - First launch is setup: it creates the admin, or on an install used before claims the existing local account so connections and history stay. A bound APP_USER_EMAIL is used as-is. - Setup from another machine (anything not a direct loopback request, so a reverse proxy or the Vite proxy counts as remote) needs a one-time code printed to the server log. Setup closes once an account can sign in, and a server already running multi-user is never offered it. - /api/auth/register answers 403; admins add users (POST /api/admin/users and an Add user form). SSO signs in existing accounts only. - Sign-in is rate-limited to 20 per 15 minutes. - users.password_set (migration 19) records who can sign in. - AUTH_REQUIRED is gone; LOCAL_SINGLE_USER now only marks a personal install for machine-level routes. The CLI resolves the same owner account. - e2e suites sign in through a cached session (apps/e2e/.env); the nightly cloud workflow makes a password per run. Co-Authored-By: Claude Opus 5.5 --- .env.example | 9 +- .github/workflows/e2e-cloud.yml | 5 + AGENTS.md | 2 +- CONTRIBUTING.md | 2 +- Dockerfile | 3 +- apps/cli/src/commands/open.ts | 1 - apps/cli/src/runtime/bootstrap.ts | 1 - apps/cli/src/runtime/ensureUiEnv.ts | 3 - apps/cli/src/runtime/store.ts | 6 +- apps/e2e/.env.example | 9 + apps/e2e/src/helpers/app-session.ts | 103 ++++++++++ apps/e2e/src/helpers/driver.ts | 5 +- apps/e2e/src/helpers/sql-exec.ts | 8 +- apps/web/package.json | 6 +- apps/web/src/frontend/App.tsx | 2 +- .../frontend/app/shell/ActivityRail.test.tsx | 1 - .../app/shell/CommandPalette.test.tsx | 1 - .../frontend/app/shell/ProfileMenu.test.tsx | 2 - .../src/frontend/app/shell/ProfileMenu.tsx | 18 +- apps/web/src/frontend/app/store/authStore.ts | 45 ++--- .../components/AccessPermissionPanel.test.tsx | 1 - .../access/components/AccessView.test.tsx | 1 - .../components/GeneratedPassword.test.tsx | 1 - .../components/AdminAccessPanel.test.tsx | 28 ++- .../admin/components/AdminAccessPanel.tsx | 108 ++++++++--- .../features/admin/lib/adminAccess.test.ts | 15 +- .../features/admin/lib/adminAccess.ts | 16 +- .../auth/components/AuthPage.test.tsx | 98 ++++++++++ .../features/auth/components/AuthPage.tsx | 131 +++++++++---- .../components/DatabaseAccessModal.test.tsx | 1 - .../workflow/components/WorkflowView.test.tsx | 1 - apps/web/src/frontend/shared/api/authApi.ts | 45 +++-- apps/web/vite.config.ts | 5 + docker-compose.app.yml | 1 - docs/ARCHITECTURE.md | 2 +- docs/DEPLOYMENT.md | 58 +++--- docs/releases/UNRELEASED.md | 24 +++ packages/server/src/api/deployment.ts | 8 +- packages/server/src/api/http-contract.test.ts | 61 +++++- packages/server/src/api/routes.ts | 2 +- packages/server/src/api/server.ts | 26 +-- packages/server/src/database/schema.ts | 15 ++ .../server/src/features/admin/admin.routes.ts | 33 +++- .../admin/app-secrets.service.test.ts | 4 +- ...cloud-provider-credentials.service.test.ts | 2 +- .../server/src/features/auth/auth.routes.ts | 80 +++++--- .../src/features/auth/auth.service.test.ts | 60 +++--- .../server/src/features/auth/auth.service.ts | 176 +++++++++++++----- .../src/features/auth/auth.setup.test.ts | 125 +++++++++++++ .../server/src/features/auth/setup-code.ts | 56 ++++++ .../src/features/auth/setup.routes.test.ts | 124 ++++++++++++ .../connection-store.service.test.ts | 4 +- .../features/users/signup-wizard.routes.ts | 2 +- .../src/features/users/user.service.test.ts | 2 +- ...workflow-connection-grants.service.test.ts | 4 +- 55 files changed, 1208 insertions(+), 344 deletions(-) create mode 100644 apps/e2e/src/helpers/app-session.ts create mode 100644 apps/web/src/frontend/features/auth/components/AuthPage.test.tsx create mode 100644 packages/server/src/features/auth/auth.setup.test.ts create mode 100644 packages/server/src/features/auth/setup-code.ts create mode 100644 packages/server/src/features/auth/setup.routes.test.ts diff --git a/.env.example b/.env.example index 10d17934..55f86759 100644 --- a/.env.example +++ b/.env.example @@ -31,14 +31,13 @@ APP_KEY_SCHEME=v1 # APP_DB_URL=mysql://user:pass@host:3306/foxmeta # ── Access mode ── -# Default is OPEN single-user (no login). Only safe behind your own reverse -# proxy / VPN — do NOT expose this mode raw to the public internet. +# Every install requires sign-in; the first launch creates the admin account +# (see docs/DEPLOYMENT.md). LOCAL_SINGLE_USER=true marks a personal install, +# which also allows machine-level actions such as driver install and updates. LOCAL_SINGLE_USER=true -AUTH_REQUIRED=false # -# For a public deployment, enable multi-user accounts + SSO (see docs/DEPLOYMENT.md): +# For a shared deployment, turn those off and add SSO (see docs/DEPLOYMENT.md): # LOCAL_SINGLE_USER=false -# AUTH_REQUIRED=true # SSO_REDIRECT_BASE=https://fox.example.com # SSO_GOOGLE_CLIENT_ID= # SSO_GOOGLE_CLIENT_SECRET= diff --git a/.github/workflows/e2e-cloud.yml b/.github/workflows/e2e-cloud.yml index 15de7ee5..0753676f 100644 --- a/.github/workflows/e2e-cloud.yml +++ b/.github/workflows/e2e-cloud.yml @@ -60,7 +60,12 @@ jobs: E2E_SQLITE_TARGET_DB=/tmp/foxschema-sqlite/demo_b.db E2E_SQLITE_TARGET_USER=sqlite E2E_SQLITE_TARGET_PASS= + E2E_APP_EMAIL=e2e-admin@foxschema.test + E2E_API_URL=http://127.0.0.1:3210 EOF + # The suites sign in; on this fresh database they create the admin + # through first-run setup with a password made for this run. + echo "E2E_APP_PASSWORD=$(openssl rand -hex 16)" >> apps/e2e/.env - name: Start app (API + Vite) run: | diff --git a/AGENTS.md b/AGENTS.md index 46ab9306..9ac12e43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Standard commands live in `CONTRIBUTING.md` and `package.json` scripts (`npm run ### Running the app - `npm run dev` runs the Fastify API (`:3210`) and Vite UI (`:5173`) together; open the UI at http://localhost:5173. API liveness: `GET http://localhost:3210/api/health` - → `{"ok":true}`. Default mode is single-user (no login). Workflow engine: + → `{"ok":true}`. Every install requires sign-in; first open runs admin setup. Workflow engine: `npm run dev:with-workflow` (needs `WORKFLOW_ENGINE_TOKEN` and `FOXFLOW_ENCRYPTION_KEY` — [docs/WORKFLOW.md](docs/WORKFLOW.md)). - Vite is configured with `server.host: true`, `server.strictPort: true`, and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51e5631f..3ea31943 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ npm install # installs the whole workspace docker compose up -d bash scripts/seed/seed-all.sh all # seed demo_a/demo_b schemas into each -npm run dev # Fastify API + Vite UI (single-user mode) +npm run dev # Fastify API + Vite UI (sign in; first open runs setup) ``` `npm run dev` serves the UI on **http://localhost:5173** and the **Fastify** API diff --git a/Dockerfile b/Dockerfile index f249ed84..82b45ef3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,8 +76,7 @@ ENV NODE_ENV=production \ APP_DB_ENGINE=sqlite \ APP_DB_PATH=/data/foxschema.db \ APP_KEY_SCHEME=v1 \ - LOCAL_SINGLE_USER=true \ - AUTH_REQUIRED=false + LOCAL_SINGLE_USER=true # APP_ENCRYPTION_KEY is optional for pull-and-run: entrypoint generates one into # /data/.app_encryption_key on first boot. Set -e APP_ENCRYPTION_KEY=… to override. diff --git a/apps/cli/src/commands/open.ts b/apps/cli/src/commands/open.ts index b91bed9b..e60de721 100644 --- a/apps/cli/src/commands/open.ts +++ b/apps/cli/src/commands/open.ts @@ -395,7 +395,6 @@ export async function runOpen(opts: OpenOptions = {}): Promise { PORT: String(port), LISTEN_HOST: '127.0.0.1', STATIC_DIR: staticDir, - AUTH_REQUIRED: 'false', LOCAL_SINGLE_USER: 'true', APP_ENCRYPTION_KEY: process.env.APP_ENCRYPTION_KEY, APP_KEY_SCHEME: process.env.APP_KEY_SCHEME || 'v1', diff --git a/apps/cli/src/runtime/bootstrap.ts b/apps/cli/src/runtime/bootstrap.ts index 1fccba1f..3c5af477 100644 --- a/apps/cli/src/runtime/bootstrap.ts +++ b/apps/cli/src/runtime/bootstrap.ts @@ -24,7 +24,6 @@ export function applyEnv(): boolean { process.env.APP_DB_URL = c.dbUrl; } process.env.EDITION = process.env.EDITION || 'community'; - process.env.AUTH_REQUIRED = 'false'; return !!dek; } diff --git a/apps/cli/src/runtime/ensureUiEnv.ts b/apps/cli/src/runtime/ensureUiEnv.ts index 31976047..c3c0f0aa 100644 --- a/apps/cli/src/runtime/ensureUiEnv.ts +++ b/apps/cli/src/runtime/ensureUiEnv.ts @@ -35,7 +35,6 @@ export function ensureUiEnv(): { source: 'keychain' | 'env' | 'file' | 'generate if (!process.env.APP_ENCRYPTION_KEY && process.env.FOXSCHEMA_KEY) { process.env.APP_ENCRYPTION_KEY = process.env.FOXSCHEMA_KEY; } - process.env.AUTH_REQUIRED = 'false'; process.env.EDITION = process.env.EDITION || 'community'; return { source: process.env.FOXSCHEMA_KEY ? 'env' : 'keychain' }; } @@ -45,7 +44,6 @@ export function ensureUiEnv(): { source: 'keychain' | 'env' | 'file' | 'generate const dek = getDek(c.email); if (dek) { applyEnv(); - process.env.AUTH_REQUIRED = 'false'; return { source: 'keychain' }; } } @@ -64,7 +62,6 @@ export function ensureUiEnv(): { source: 'keychain' | 'env' | 'file' | 'generate const dbPath = c.dbPath || DEFAULT_DB_PATH; mkdirSync(dirname(dbPath), { recursive: true }); process.env.APP_DB_PATH = dbPath; - process.env.AUTH_REQUIRED = 'false'; process.env.EDITION = process.env.EDITION || 'community'; process.env.LOCAL_SINGLE_USER = 'true'; diff --git a/apps/cli/src/runtime/store.ts b/apps/cli/src/runtime/store.ts index 677c5a04..a4026ce2 100644 --- a/apps/cli/src/runtime/store.ts +++ b/apps/cli/src/runtime/store.ts @@ -13,13 +13,15 @@ let ctx: CliContext | null = null; /** * Ready-to-use context: applies the stored config + keychain key to the env - * (so the shared store/crypto run), ensures the local user, and returns the + * (so the shared store/crypto run), resolves the install owner, and returns the * connection + history stores. Throws a clear message if not set up. */ export async function getContext(): Promise { if (ctx) return ctx; requireReady(); - const user = await new AuthModule().ensureLocalUser(); + // The CLI acts as the install owner — the same account the app's first-run + // setup claims, so both see the same connections and history. + const user = await new AuthModule().ownerAccount(); ctx = { userId: user.id, connections: new ConnectionStore(), history: new MigrationHistoryStore() }; return ctx; } diff --git a/apps/e2e/.env.example b/apps/e2e/.env.example index 3ea4ab1b..31684c29 100644 --- a/apps/e2e/.env.example +++ b/apps/e2e/.env.example @@ -1,5 +1,14 @@ # Copy to apps/e2e/.env (gitignored). Matches docker-compose.yml Postgres. E2E_BASE_URL=http://127.0.0.1:5173 + +# The Fox account the browser suites sign in as. Every install requires sign-in; +# on a fresh dev database the suites create this admin through first-run setup +# (on the API port directly, so no setup code is needed). If you already set up +# this dev instance by hand, put an admin account of yours here instead. +E2E_APP_EMAIL=e2e-admin@foxschema.test +E2E_APP_PASSWORD=e2e-3e2cf9bc8f9dc5d661 +# The API itself, for setup and sign-in (not through the Vite proxy). +E2E_API_URL=http://127.0.0.1:3210 E2E_POSTGRES_SOURCE_HOST=127.0.0.1 E2E_POSTGRES_SOURCE_PORT=5432 E2E_POSTGRES_SOURCE_DB=foxdb diff --git a/apps/e2e/src/helpers/app-session.ts b/apps/e2e/src/helpers/app-session.ts new file mode 100644 index 00000000..5bc810ae --- /dev/null +++ b/apps/e2e/src/helpers/app-session.ts @@ -0,0 +1,103 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Signing the browser suites in. + * + * Every install requires sign-in. The suites use one admin account + * (`E2E_APP_EMAIL` / `E2E_APP_PASSWORD` in apps/e2e/.env). On a dev database + * nobody has set up yet, they create it through first-run setup; after that + * they sign in. + * + * Both calls go to the API port directly. Through the Vite proxy a request + * carries forwarding headers, so the API treats it as remote and setup would + * need the code from the server log. + * + * The session is cached in a temp file and reused across processes while it is + * still valid: `run-all.mjs` starts a process per suite, and signing in two + * dozen times would run into the sign-in limit (20 per 15 minutes). + */ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Page } from 'playwright'; + +const API_URL = process.env.E2E_API_URL ?? 'http://127.0.0.1:3210'; +const EMAIL = process.env.E2E_APP_EMAIL ?? 'e2e-admin@foxschema.test'; +const PASSWORD = process.env.E2E_APP_PASSWORD ?? ''; + +const CACHE_DIR = join(tmpdir(), 'foxschema-e2e'); +const CACHE_FILE = join(CACHE_DIR, `session-${Buffer.from(`${API_URL}|${EMAIL}`).toString('hex')}.txt`); + +/** The session cookie's name and value, `sid=…`. */ +let cached: string | undefined; + +async function stillValid(cookie: string): Promise { + try { + const res = await fetch(`${API_URL}/api/auth/me`, { headers: { cookie } }); + const body = (await res.json()) as { user?: unknown }; + return res.ok && !!body.user; + } catch { + return false; + } +} + +function readCache(): string | undefined { + try { + return readFileSync(CACHE_FILE, 'utf8').trim() || undefined; + } catch { + return undefined; + } +} + +function writeCache(cookie: string): void { + try { + mkdirSync(CACHE_DIR, { recursive: true }); + writeFileSync(CACHE_FILE, cookie, { mode: 0o600 }); + } catch { + /* a cache, not a requirement */ + } +} + +async function post(path: string, body: unknown): Promise { + return fetch(`${API_URL}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +/** Sign in (running first-run setup if needed) and return `sid=…`. */ +export async function sessionCookie(): Promise { + if (cached && (await stillValid(cached))) return cached; + const fromFile = readCache(); + if (fromFile && (await stillValid(fromFile))) return (cached = fromFile); + + if (!PASSWORD) { + throw new Error('E2E_APP_PASSWORD is not set. Copy it from apps/e2e/.env.example into apps/e2e/.env.'); + } + + const setup = (await (await fetch(`${API_URL}/api/auth/setup`)).json()) as { setupRequired?: boolean }; + const res = setup.setupRequired + ? await post('/api/auth/setup', { email: EMAIL, password: PASSWORD }) + : await post('/api/auth/login', { email: EMAIL, password: PASSWORD }); + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error( + `Could not sign the e2e suites in as ${EMAIL} (HTTP ${res.status} ${detail}). ` + + 'If this dev instance was set up by hand, set E2E_APP_EMAIL / E2E_APP_PASSWORD in ' + + 'apps/e2e/.env to an admin account on it.' + ); + } + const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0] ?? ''; + if (!cookie.startsWith('sid=')) throw new Error('Sign-in answered without a session cookie.'); + writeCache(cookie); + return (cached = cookie); +} + +/** Put the session on the browser, for pages served at `baseUrl`. */ +export async function signInBrowser(page: Page, baseUrl: string): Promise { + const [name, ...rest] = (await sessionCookie()).split('='); + await page.context().addCookies([{ name: name!, value: rest.join('='), url: baseUrl }]); +} diff --git a/apps/e2e/src/helpers/driver.ts b/apps/e2e/src/helpers/driver.ts index 303620a4..a78d718a 100644 --- a/apps/e2e/src/helpers/driver.ts +++ b/apps/e2e/src/helpers/driver.ts @@ -1,11 +1,12 @@ import { chromium, type Browser, type Page, type Locator } from 'playwright'; +import { signInBrowser } from './app-session.js'; export const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; // Track which Browser owns each Page so quitDriver can close both. const pageToBrowser = new Map(); -/** Launch a Chromium browser and return its first Page. Set HEADLESS=false to watch. */ +/** Launch a signed-in Chromium browser and return its first Page. Set HEADLESS=false to watch. */ export async function buildDriver(): Promise { const headless = process.env.HEADLESS !== 'false'; const browser = await chromium.launch({ @@ -18,6 +19,8 @@ export async function buildDriver(): Promise { const page = await browser.newPage(); await page.setViewportSize({ width: 1440, height: 900 }); pageToBrowser.set(page, browser); + // Every install requires sign-in; each suite's browser starts signed in. + await signInBrowser(page, BASE_URL); return page; } diff --git a/apps/e2e/src/helpers/sql-exec.ts b/apps/e2e/src/helpers/sql-exec.ts index 3f668a80..229f9987 100644 --- a/apps/e2e/src/helpers/sql-exec.ts +++ b/apps/e2e/src/helpers/sql-exec.ts @@ -20,6 +20,7 @@ * reported rather than tolerated. */ import { getSourceConfig, type DbConfig } from './db-config.js'; +import { sessionCookie } from './app-session.js'; const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; @@ -104,7 +105,7 @@ export async function runStatements( const res = await fetch(`${BASE_URL}/api/sql/execute`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', cookie: await sessionCookie() }, body: JSON.stringify({ dialect, option: optionOf(cfg), statements }), }); const body = (await res.json()) as { @@ -182,7 +183,8 @@ export async function deleteSavedConnections(names: readonly string[]): Promise< if (names.length === 0) return 0; const wanted = new Set(names); try { - const res = await fetch(`${BASE_URL}/api/connections`); + const cookie = await sessionCookie(); + const res = await fetch(`${BASE_URL}/api/connections`, { headers: { cookie } }); const body = (await res.json()) as unknown; const rows = Array.isArray(body) ? (body as Array<{ id?: string; name?: string }>) @@ -191,7 +193,7 @@ export async function deleteSavedConnections(names: readonly string[]): Promise< let removed = 0; for (const row of rows) { if (!row?.id || !row.name || !wanted.has(row.name)) continue; - const gone = await fetch(`${BASE_URL}/api/connections/${row.id}`, { method: 'DELETE' }) + const gone = await fetch(`${BASE_URL}/api/connections/${row.id}`, { method: 'DELETE', headers: { cookie } }) .then((r) => r.ok) .catch(() => false); if (gone) removed++; diff --git a/apps/web/package.json b/apps/web/package.json index 3f491d89..fa803d84 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,11 +8,11 @@ }, "scripts": { "dev": "vite", - "dev:api": "cross-env AUTH_REQUIRED=true APP_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 tsx watch ../../packages/server/src/main.ts", + "dev:api": "cross-env APP_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 tsx watch ../../packages/server/src/main.ts", "dev:all": "concurrently -n api,web -c cyan,magenta \"npm run dev:api\" \"npm run dev\"", "dev:all:workflow": "concurrently -n api,web,workflow -c cyan,magenta,green \"npm run dev:api\" \"npm run dev\" \"npm -w @foxschema/workflow-server run dev\"", - "dev:auth": "cross-env AUTH_REQUIRED=true LOCAL_SINGLE_USER=false vite", - "dev:api:auth": "cross-env AUTH_REQUIRED=true LOCAL_SINGLE_USER=false APP_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 tsx watch ../../packages/server/src/main.ts", + "dev:auth": "cross-env LOCAL_SINGLE_USER=false vite", + "dev:api:auth": "cross-env LOCAL_SINGLE_USER=false APP_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 tsx watch ../../packages/server/src/main.ts", "dev:all:auth": "concurrently -n api,web -c cyan,magenta \"npm run dev:api:auth\" \"npm run dev:auth\"", "build": "tsc && vite build", "preview": "vite preview", diff --git a/apps/web/src/frontend/App.tsx b/apps/web/src/frontend/App.tsx index 6706b726..ea8e434e 100644 --- a/apps/web/src/frontend/App.tsx +++ b/apps/web/src/frontend/App.tsx @@ -232,7 +232,7 @@ const App: React.FC = () => { ); } - if (status === 'anon') return ; + if (status === 'anon' || status === 'setup') return ; if (status === 'onboarding') return ; return ; }; diff --git a/apps/web/src/frontend/app/shell/ActivityRail.test.tsx b/apps/web/src/frontend/app/shell/ActivityRail.test.tsx index 17c3475f..a60e457c 100644 --- a/apps/web/src/frontend/app/shell/ActivityRail.test.tsx +++ b/apps/web/src/frontend/app/shell/ActivityRail.test.tsx @@ -21,7 +21,6 @@ describe('ActivityRail', () => { permissions: [...DEFAULT_ROLE_PERMISSIONS.owner], }, status: 'ready', - localSingleUser: true, error: null, busy: false, refreshMe: vi.fn(async () => {}), diff --git a/apps/web/src/frontend/app/shell/CommandPalette.test.tsx b/apps/web/src/frontend/app/shell/CommandPalette.test.tsx index 2ec0c440..ffaa7f0e 100644 --- a/apps/web/src/frontend/app/shell/CommandPalette.test.tsx +++ b/apps/web/src/frontend/app/shell/CommandPalette.test.tsx @@ -48,7 +48,6 @@ describe('CommandPalette', () => { permissions: [...DEFAULT_ROLE_PERMISSIONS.owner], }, status: 'ready', - localSingleUser: true, error: null, busy: false, refreshMe: vi.fn(async () => {}), diff --git a/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx b/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx index 6e1f9b08..45f73863 100644 --- a/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx +++ b/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx @@ -35,7 +35,6 @@ beforeEach(() => { permissions: [], }, status: 'ready', - localSingleUser: true, error: null, busy: false, }); @@ -58,7 +57,6 @@ describe('ProfileMenu', () => { role: 'editor', permissions: [...DEFAULT_ROLE_PERMISSIONS.editor], }, - localSingleUser: false, }); render(); fireEvent.click(screen.getByTestId('profile-menu-trigger')); diff --git a/apps/web/src/frontend/app/shell/ProfileMenu.tsx b/apps/web/src/frontend/app/shell/ProfileMenu.tsx index 28329d53..6c553a1d 100644 --- a/apps/web/src/frontend/app/shell/ProfileMenu.tsx +++ b/apps/web/src/frontend/app/shell/ProfileMenu.tsx @@ -9,7 +9,7 @@ import { maybeToastUpdateAvailable } from '@/app/shell/updateToast'; import { AdminAccessPanel } from '@/features/admin'; export function ProfileMenu(): React.ReactElement | null { - const { user, logout, localSingleUser } = useAuthStore(); + const { user, logout } = useAuthStore(); const setActiveView = useUiStore((s) => s.setActiveView); const canAdminAccess = useAuthStore((s) => s.can('admin.users') || s.can('admin.roles')); const [open, setOpen] = useState(false); @@ -117,15 +117,13 @@ export function ProfileMenu(): React.ReactElement | null { foxschema.com - {!localSingleUser && ( - - )} + , document.body ) diff --git a/apps/web/src/frontend/app/store/authStore.ts b/apps/web/src/frontend/app/store/authStore.ts index 3388ef54..16181438 100644 --- a/apps/web/src/frontend/app/store/authStore.ts +++ b/apps/web/src/frontend/app/store/authStore.ts @@ -7,37 +7,31 @@ import { create } from 'zustand'; import { apiMe, apiLogin, - apiRegister, apiLogout, apiPutPreferences, - apiAppConfig, + apiSetup, + apiSetupState, type AuthUser, + type SetupState, type UserPreferences, } from '@/shared/api/authApi'; import type { Permission } from '@/shared/lib/permissions'; import { userCan } from '@/shared/lib/permissions'; -type AuthStatus = 'loading' | 'anon' | 'onboarding' | 'ready'; - -const LOCAL_USER: AuthUser = { - id: 'local', - email: 'local@foxschema.app', - onboardingCompleted: true, - role: 'admin', - permissions: [], -}; +/** `setup`: no account can sign in yet, so the first admin must be created. */ +type AuthStatus = 'loading' | 'setup' | 'anon' | 'onboarding' | 'ready'; interface AuthState { status: AuthStatus; user: AuthUser | null; error: string | null; busy: boolean; - /** True when the API runs in local single-user mode (no login UI). */ - localSingleUser: boolean; + /** First-run setup details, while status is `setup`. */ + setupState: SetupState | null; init: () => Promise; login: (email: string, password: string) => Promise; - register: (email: string, password: string) => Promise; + setup: (email: string, password: string, code?: string) => Promise; logout: () => Promise; completeOnboarding: (prefs: Partial) => Promise; refreshMe: () => Promise; @@ -55,23 +49,22 @@ export const useAuthStore = create((set, get) => ({ user: null, error: null, busy: false, - localSingleUser: true, + setupState: null, can: (permission) => userCan(get().user, permission), + // Every install signs in. Until one account can, the first admin is set up. init: async () => { - const cfg = await apiAppConfig(); - set({ localSingleUser: cfg.localSingleUser }); + const setupState = await apiSetupState(); + if (setupState.setupRequired) { + set({ setupState, user: null, status: 'setup' }); + return; + } await get().refreshMe(); }, - /** Prefer the server-enriched user (real id + permissions) when available. */ refreshMe: async () => { const user = await apiMe(); - if (!user && get().localSingleUser) { - set({ user: LOCAL_USER, status: 'ready' }); - return; - } set({ user, status: statusFor(user) }); }, @@ -88,14 +81,14 @@ export const useAuthStore = create((set, get) => ({ } }, - register: async (email, password) => { + setup: async (email, password, code) => { set({ busy: true, error: null }); try { - const user = await apiRegister(email, password); - set({ user, status: statusFor(user), busy: false }); + const user = await apiSetup(email, password, code); + set({ user, setupState: null, status: statusFor(user), busy: false }); } catch (e: unknown) { set({ - error: e instanceof Error ? e.message : 'Registration failed', + error: e instanceof Error ? e.message : 'Setup failed', busy: false, }); } diff --git a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx index 2e85df23..dc8a42d8 100644 --- a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx +++ b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx @@ -117,7 +117,6 @@ beforeEach(() => { permissions: [...DEFAULT_ROLE_PERMISSIONS.owner], }, status: 'ready', - localSingleUser: false, error: null, busy: false, refreshMe: vi.fn(async () => {}), diff --git a/apps/web/src/frontend/features/access/components/AccessView.test.tsx b/apps/web/src/frontend/features/access/components/AccessView.test.tsx index 99c86a3f..e8170061 100644 --- a/apps/web/src/frontend/features/access/components/AccessView.test.tsx +++ b/apps/web/src/frontend/features/access/components/AccessView.test.tsx @@ -58,7 +58,6 @@ beforeEach(() => { permissions: [...DEFAULT_ROLE_PERMISSIONS.owner], }, status: 'ready', - localSingleUser: false, error: null, busy: false, refreshMe: vi.fn(async () => {}), diff --git a/apps/web/src/frontend/features/access/components/GeneratedPassword.test.tsx b/apps/web/src/frontend/features/access/components/GeneratedPassword.test.tsx index 5a3d66d0..6d240dc2 100644 --- a/apps/web/src/frontend/features/access/components/GeneratedPassword.test.tsx +++ b/apps/web/src/frontend/features/access/components/GeneratedPassword.test.tsx @@ -90,7 +90,6 @@ beforeEach(() => { permissions: [...DEFAULT_ROLE_PERMISSIONS.owner], }, status: 'ready', - localSingleUser: false, error: null, busy: false, refreshMe: vi.fn(async () => {}), diff --git a/apps/web/src/frontend/features/admin/components/AdminAccessPanel.test.tsx b/apps/web/src/frontend/features/admin/components/AdminAccessPanel.test.tsx index 25368792..9c1154c9 100644 --- a/apps/web/src/frontend/features/admin/components/AdminAccessPanel.test.tsx +++ b/apps/web/src/frontend/features/admin/components/AdminAccessPanel.test.tsx @@ -9,6 +9,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { DEFAULT_ROLE_PERMISSIONS, PERMISSION_META } from '@foxschema/shared'; import { useAuthStore } from '@/app/store/authStore'; +const apiAdminCreateUser = vi.fn(); const apiAdminListUsers = vi.fn(); const apiAdminRolePermissions = vi.fn(); const apiAdminSetRolePermissions = vi.fn(); @@ -17,6 +18,7 @@ const apiAdminSetUserPassword = vi.fn(); const apiAdminSetUserRole = vi.fn(); vi.mock('@/shared/api/authApi', () => ({ + apiAdminCreateUser: (...args: unknown[]) => apiAdminCreateUser(...args), apiAdminListUsers: (...args: unknown[]) => apiAdminListUsers(...args), apiAdminRolePermissions: (...args: unknown[]) => apiAdminRolePermissions(...args), apiAdminSetRolePermissions: (...args: unknown[]) => apiAdminSetRolePermissions(...args), @@ -37,6 +39,7 @@ const localUser = { }; beforeEach(() => { + apiAdminCreateUser.mockReset(); apiAdminListUsers.mockReset(); apiAdminRolePermissions.mockReset(); apiAdminSetRolePermissions.mockReset(); @@ -67,7 +70,6 @@ beforeEach(() => { permissions: [], }, status: 'ready', - localSingleUser: true, error: null, busy: false, refreshMe: vi.fn(async () => {}), @@ -75,7 +77,7 @@ beforeEach(() => { }); describe('AdminAccessPanel', () => { - it('locks role and Active for the local single-user admin', async () => { + it('locks role and Active for the only admin, and for your own account', async () => { render( undefined} />); await waitFor(() => { @@ -85,7 +87,6 @@ describe('AdminAccessPanel', () => { expect(screen.getByTestId('admin-tab-users').textContent).toMatch(/app users/i); expect(screen.getByTestId('admin-tab-roles').textContent).toMatch(/app roles/i); expect(screen.getByTestId('admin-tab-users-roles').textContent).toMatch(/users and roles/i); - expect(screen.getByTestId('admin-single-user-hint').textContent).toMatch(/single-user/i); expect((screen.getByTestId(`admin-user-role-${localUser.id}`) as HTMLSelectElement).disabled).toBe( true ); @@ -94,6 +95,23 @@ describe('AdminAccessPanel', () => { ); }); + it('adds an account, since nobody can register themselves', async () => { + apiAdminCreateUser.mockResolvedValue(undefined); + render( undefined} />); + await waitFor(() => expect(screen.getByTestId('admin-add-user')).toBeTruthy()); + + fireEvent.change(screen.getByLabelText(/add user/i), { target: { value: 'Teammate@Example.com' } }); + fireEvent.change(screen.getByLabelText(/starting password/i), { target: { value: 'teammate-pass' } }); + fireEvent.change(screen.getByLabelText(/^role$/i), { target: { value: 'editor' } }); + fireEvent.submit(screen.getByTestId('admin-add-user')); + + await waitFor(() => + expect(apiAdminCreateUser).toHaveBeenCalledWith('Teammate@Example.com', 'teammate-pass', 'editor') + ); + // The list is read again so the new account appears. + await waitFor(() => expect(apiAdminListUsers).toHaveBeenCalledTimes(2)); + }); + it('keeps Save visible and persists checkbox edits for a non-admin role', async () => { render( undefined} />); @@ -201,7 +219,6 @@ describe('AdminAccessPanel', () => { role: 'viewer', permissions: [...DEFAULT_ROLE_PERMISSIONS.viewer], }, - localSingleUser: false, }); render( undefined} />); expect(screen.getByTestId('admin-access-panel')).toBeTruthy(); @@ -220,7 +237,6 @@ describe('AdminAccessPanel', () => { role: 'editor', permissions: [...DEFAULT_ROLE_PERMISSIONS.editor], }, - localSingleUser: false, }); render( undefined} />); await waitFor(() => expect(screen.getByTestId('admin-tab-users-roles')).toBeTruthy()); @@ -240,7 +256,6 @@ describe('AdminAccessPanel', () => { role: 'owner', permissions: [...DEFAULT_ROLE_PERMISSIONS.owner, 'admin.roles'], }, - localSingleUser: false, }); render( undefined} />); await waitFor(() => expect(screen.getByTestId('admin-tab-roles')).toBeTruthy()); @@ -258,7 +273,6 @@ describe('AdminAccessPanel', () => { permissions: [...DEFAULT_ROLE_PERMISSIONS.editor], }; apiAdminListUsers.mockResolvedValue({ users: [localUser, editorUser] }); - useAuthStore.setState({ localSingleUser: false }); render( undefined} />); diff --git a/apps/web/src/frontend/features/admin/components/AdminAccessPanel.tsx b/apps/web/src/frontend/features/admin/components/AdminAccessPanel.tsx index b5720d2b..54ba2bc3 100644 --- a/apps/web/src/frontend/features/admin/components/AdminAccessPanel.tsx +++ b/apps/web/src/frontend/features/admin/components/AdminAccessPanel.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { ChevronDown, ChevronRight, KeyRound, Loader2, Shield, UserCog, Users, X } from 'lucide-react'; import { + apiAdminCreateUser, apiAdminListUsers, apiAdminRolePermissions, apiAdminSetRolePermissions, @@ -52,7 +53,6 @@ export const AdminAccessPanel: React.FC<{ open: boolean; onClose: () => void }> }) => { const refreshMe = useAuthStore((s) => s.refreshMe); const me = useAuthStore((s) => s.user); - const localSingleUser = useAuthStore((s) => s.localSingleUser); const canUsers = useAuthStore((s) => s.can('admin.users')); const canRoles = useAuthStore((s) => s.can('admin.roles')); const canUsersRoles = useAuthStore((s) => s.can('utility.access')); @@ -70,6 +70,9 @@ export const AdminAccessPanel: React.FC<{ open: boolean; onClose: () => void }> const [confirmPassword, setConfirmPassword] = useState(''); const [passwordMsg, setPasswordMsg] = useState(null); const [expandedUserIds, setExpandedUserIds] = useState>(() => new Set()); + const [addEmail, setAddEmail] = useState(''); + const [addPassword, setAddPassword] = useState(''); + const [addRole, setAddRole] = useState('viewer'); const load = useCallback(async () => { setBusy(true); @@ -241,6 +244,26 @@ export const AdminAccessPanel: React.FC<{ open: boolean; onClose: () => void }> } }; + /** No self-registration: this is how anyone besides the first admin gets in. */ + const addUser = async (e: React.FormEvent) => { + e.preventDefault(); + setBusy(true); + setError(null); + setSavedMsg(null); + try { + await apiAdminCreateUser(addEmail, addPassword, addRole); + setSavedMsg(`Added ${addEmail.trim().toLowerCase()} as ${addRole}. Share the password with them directly.`); + setAddEmail(''); + setAddPassword(''); + setAddRole('viewer'); + await load(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Could not add the account'); + } finally { + setBusy(false); + } + }; + const saveRolePerms = async () => { if (editRole === 'admin') return; setBusy(true); @@ -364,26 +387,68 @@ export const AdminAccessPanel: React.FC<{ open: boolean; onClose: () => void }> {tab === 'users' && canUsers && ( <> - {localSingleUser && ( -

+ FoxSchema logins grouped by app role. Expand a row to see that role’s permissions — + they are not per-user overrides. Who can access what on the database is on Users + and Roles; GRANT / REVOKE is under Access → Permission. +

+
+
+ + setAddEmail(e.target.value)} + placeholder="teammate@company.com" + className="rounded-md border border-slate-800 bg-slate-950 px-2 py-1.5 text-xs outline-none accent-focus" + /> +
+
+ + setAddPassword(e.target.value)} + placeholder="At least 8 characters" + autoComplete="new-password" + className="w-full rounded-md border border-slate-800 bg-slate-950 px-2 py-1.5 text-xs outline-none accent-focus" + /> +
+
+ + +
+ +
{userGroups.map((group) => (
void }> ) : (
    {group.users.map((u) => { - const roleLock = userRoleSelectLock(u, { busy, localSingleUser, users }); + const roleLock = userRoleSelectLock(u, { busy, users }); const activeLock = userActiveCheckboxLock(u, { busy, - localSingleUser, meId: me?.id, users, }); diff --git a/apps/web/src/frontend/features/admin/lib/adminAccess.test.ts b/apps/web/src/frontend/features/admin/lib/adminAccess.test.ts index a5d21c5f..db727281 100644 --- a/apps/web/src/frontend/features/admin/lib/adminAccess.test.ts +++ b/apps/web/src/frontend/features/admin/lib/adminAccess.test.ts @@ -34,27 +34,22 @@ describe('permissionSetEqual', () => { }); }); -describe('last-admin / single-user locks', () => { +describe('last-admin and own-account locks', () => { it('counts only active admins', () => { expect(activeAdminCount([admin, editor, inactiveAdmin])).toBe(1); expect(activeAdminCount([admin, { ...admin, id: 'a2' }])).toBe(2); }); - it('locks the local singleton role and Active checkbox', () => { - const role = userRoleSelectLock(admin, { localSingleUser: true, users: [admin] }); - expect(role.disabled).toBe(true); - expect(role.reason).toMatch(/single-user/i); - + it('never lets you deactivate your own account', () => { const active = userActiveCheckboxLock(admin, { - localSingleUser: true, meId: admin.id, - users: [admin], + users: [admin, { ...admin, id: 'a2' }], }); expect(active.disabled).toBe(true); - expect(active.reason).toMatch(/single-user/i); + expect(active.reason).toMatch(/your own account/i); }); - it('locks the last active admin’s role even in multi-user mode', () => { + it('locks the last active admin’s role', () => { const lock = userRoleSelectLock(admin, { users: [admin, editor] }); expect(lock.disabled).toBe(true); expect(lock.reason).toMatch(/last active admin/i); diff --git a/apps/web/src/frontend/features/admin/lib/adminAccess.ts b/apps/web/src/frontend/features/admin/lib/adminAccess.ts index 968f0021..19d9f6a2 100644 --- a/apps/web/src/frontend/features/admin/lib/adminAccess.ts +++ b/apps/web/src/frontend/features/admin/lib/adminAccess.ts @@ -39,16 +39,9 @@ export type ControlLock = { disabled: boolean; reason?: string }; export function userRoleSelectLock( user: AdminUserLike, - opts: { busy?: boolean; localSingleUser?: boolean; users: readonly AdminUserLike[] } + opts: { busy?: boolean; users: readonly AdminUserLike[] } ): ControlLock { if (opts.busy) return { disabled: true }; - if (opts.localSingleUser) { - return { - disabled: true, - reason: - 'Single-user mode keeps this account as admin. Enable multi-user login to change roles.', - }; - } if (isActiveAdmin(user) && activeAdminCount(opts.users) <= 1) { return { disabled: true, reason: 'Cannot change the last active admin’s role' }; } @@ -60,17 +53,10 @@ export function userActiveCheckboxLock( opts: { busy?: boolean; meId?: string; - localSingleUser?: boolean; users: readonly AdminUserLike[]; } ): ControlLock { if (opts.busy) return { disabled: true }; - if (opts.localSingleUser) { - return { - disabled: true, - reason: 'Single-user mode cannot deactivate this account.', - }; - } if (user.id === opts.meId) { return { disabled: true, reason: 'You cannot deactivate your own account' }; } diff --git a/apps/web/src/frontend/features/auth/components/AuthPage.test.tsx b/apps/web/src/frontend/features/auth/components/AuthPage.test.tsx new file mode 100644 index 00000000..a383e597 --- /dev/null +++ b/apps/web/src/frontend/features/auth/components/AuthPage.test.tsx @@ -0,0 +1,98 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Every install signs in. Before any account can, the sign-in page is + * first-run setup; after, it is sign-in with no way to register yourself. + */ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const apiSetupState = vi.fn(); +const apiSetup = vi.fn(); +const apiMe = vi.fn(); +const apiLogin = vi.fn(); + +vi.mock('@/shared/api/authApi', () => ({ + apiSetupState: (...a: unknown[]) => apiSetupState(...a), + apiSetup: (...a: unknown[]) => apiSetup(...a), + apiMe: (...a: unknown[]) => apiMe(...a), + apiLogin: (...a: unknown[]) => apiLogin(...a), + apiLogout: vi.fn(async () => undefined), + apiPutPreferences: vi.fn(async () => ({})), +})); +vi.mock('./SsoButtons', () => ({ SsoButtons: () => null })); + +import { useAuthStore } from '@/app/store/authStore'; +import { AuthPage } from './AuthPage'; + +const ADMIN = { id: 'u1', email: 'owner@example.com', onboardingCompleted: true, role: 'admin', permissions: [] }; + +beforeEach(() => { + for (const m of [apiSetupState, apiSetup, apiMe, apiLogin]) m.mockReset(); + useAuthStore.setState({ status: 'loading', user: null, error: null, busy: false, setupState: null }); +}); + +const type = (label: RegExp, value: string) => + fireEvent.change(screen.getByLabelText(label), { target: { value } }); + +describe('the sign-in page', () => { + it('goes to setup when no account can sign in yet', async () => { + apiSetupState.mockResolvedValue({ setupRequired: true, setupEmail: null, setupCodeRequired: false }); + await useAuthStore.getState().init(); + expect(useAuthStore.getState().status).toBe('setup'); + expect(apiMe).not.toHaveBeenCalled(); + }); + + it('creates the admin account, refusing mismatched passwords first', async () => { + apiSetup.mockResolvedValue(ADMIN); + useAuthStore.setState({ + status: 'setup', + setupState: { setupRequired: true, setupEmail: null, setupCodeRequired: false }, + }); + render(); + expect(screen.queryByLabelText(/setup code/i)).toBeNull(); + + type(/^email$/i, 'owner@example.com'); + type(/^password$/i, 'owner-pass-1'); + type(/confirm password/i, 'owner-pass-2'); + fireEvent.submit(screen.getByTestId('auth-setup-form')); + expect(screen.getByText(/do not match/i)).toBeTruthy(); + expect(apiSetup).not.toHaveBeenCalled(); + + type(/confirm password/i, 'owner-pass-1'); + fireEvent.submit(screen.getByTestId('auth-setup-form')); + await waitFor(() => expect(apiSetup).toHaveBeenCalledWith('owner@example.com', 'owner-pass-1', undefined)); + await waitFor(() => expect(useAuthStore.getState().status).toBe('ready')); + }); + + it('uses the bound email and asks for the setup code when not on the server machine', async () => { + apiSetup.mockResolvedValue(ADMIN); + useAuthStore.setState({ + status: 'setup', + setupState: { setupRequired: true, setupEmail: 'bound@example.com', setupCodeRequired: true }, + }); + render(); + const email = screen.getByLabelText(/^email$/i) as HTMLInputElement; + expect(email.value).toBe('bound@example.com'); + expect(email.readOnly).toBe(true); + + type(/^password$/i, 'owner-pass-1'); + type(/confirm password/i, 'owner-pass-1'); + type(/setup code/i, 'ABCD-EFGH'); + fireEvent.submit(screen.getByTestId('auth-setup-form')); + await waitFor(() => + expect(apiSetup).toHaveBeenCalledWith('bound@example.com', 'owner-pass-1', 'ABCD-EFGH') + ); + }); + + it('offers sign-in only, with no way to register', async () => { + useAuthStore.setState({ status: 'anon', setupState: null }); + render(); + expect(screen.getByTestId('auth-login-form')).toBeTruthy(); + expect(screen.queryByText(/sign up/i)).toBeNull(); + expect(screen.getByText(/ask your administrator/i)).toBeTruthy(); + }); +}); diff --git a/apps/web/src/frontend/features/auth/components/AuthPage.tsx b/apps/web/src/frontend/features/auth/components/AuthPage.tsx index 925e5a75..8ea218e2 100644 --- a/apps/web/src/frontend/features/auth/components/AuthPage.tsx +++ b/apps/web/src/frontend/features/auth/components/AuthPage.tsx @@ -12,23 +12,43 @@ function readSsoError(): string | null { return err ? decodeURIComponent(err) : null; } +const inputCls = + 'bg-slate-950 border border-slate-800 accent-focus rounded-md px-3 py-2 text-sm outline-none'; +const labelCls = 'text-xs font-semibold text-slate-400 uppercase tracking-wider'; + +/** + * Sign in, or on first run create the admin account. + * + * Every install signs in; there is no self-registration. Until an account can + * sign in, this page is first-run setup, which on an install used before keeps + * its saved connections and history. + */ export const AuthPage: React.FC = () => { - const { login, register, error, busy, clearError } = useAuthStore(); - const [mode, setMode] = useState<'login' | 'register'>('login'); - const [email, setEmail] = useState(''); + const { login, setup, setupState, status, error, busy } = useAuthStore(); + const settingUp = status === 'setup' && !!setupState; + const boundEmail = setupState?.setupEmail ?? null; + const [email, setEmail] = useState(boundEmail ?? ''); const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [code, setCode] = useState(''); + const [mismatch, setMismatch] = useState(false); const [ssoError] = useState(readSsoError); const submit = (e: React.FormEvent) => { e.preventDefault(); - if (mode === 'login') login(email, password); - else register(email, password); + if (!settingUp) { + login(email, password); + return; + } + if (password !== confirm) { + setMismatch(true); + return; + } + setMismatch(false); + setup(boundEmail ?? email, password, setupState?.setupCodeRequired ? code : undefined); }; - const switchMode = (next: 'login' | 'register') => { - clearError(); - setMode(next); - }; + const shownError = mismatch ? 'The two passwords do not match.' : error || ssoError; return (
    @@ -36,42 +56,87 @@ export const AuthPage: React.FC = () => {

    - {mode === 'login' ? 'Sign in to your workspace' : 'Create your account'} + {settingUp ? 'Create the administrator account' : 'Sign in to your workspace'}

    - First-time sign-in opens a short setup wizard before you reach the workspace. + {settingUp + ? 'Fox now asks everyone to sign in. Choose the password for this install. Saved connections and history stay as they are.' + : 'First-time sign-in opens a short setup wizard before you reach the workspace.'}

    -
    +
    - + setEmail(e.target.value)} placeholder="your@email.com" - className="bg-slate-950 border border-slate-800 accent-focus rounded-md px-3 py-2 text-sm outline-none" + autoComplete={settingUp ? 'email' : 'username'} + className={`${inputCls} read-only:text-slate-400`} />
    - + setPassword(e.target.value)} - placeholder={mode === 'register' ? 'At least 8 characters' : '••••••••'} - className="w-full bg-slate-950 border border-slate-800 accent-focus rounded-md px-3 py-2 text-sm outline-none" + placeholder={settingUp ? 'At least 8 characters' : '••••••••'} + autoComplete={settingUp ? 'new-password' : 'current-password'} + className={`w-full ${inputCls}`} />
    - {(error || ssoError) && ( + {settingUp && ( +
    + + setConfirm(e.target.value)} + autoComplete="new-password" + className={`w-full ${inputCls}`} + /> +
    + )} + + {settingUp && setupState?.setupCodeRequired && ( +
    + + setCode(e.target.value)} + placeholder="ABCD-EFGH" + autoComplete="one-time-code" + className={`${inputCls} font-mono uppercase`} + /> +

    + You are not on the machine Fox runs on, so enter the code printed in the Fox server + log. +

    +
    + )} + + {shownError && (
    - {error || ssoError} + {shownError}
    )} @@ -81,28 +146,16 @@ export const AuthPage: React.FC = () => { className="flex items-center justify-center gap-2 accent-grad disabled:opacity-60 on-accent-fg font-bold rounded-md py-2.5 text-sm transition cursor-pointer" > {busy && } - {mode === 'login' ? 'Sign In' : 'Create Account'} + {settingUp ? 'Create admin account' : 'Sign In'} - + {!settingUp && } -

    - {mode === 'login' ? ( - <> - No account?{' '} - - - ) : ( - <> - Already have an account?{' '} - - - )} -

    + {!settingUp && ( +

    + No account? Ask your administrator to add you. +

    + )}
diff --git a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.test.tsx b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.test.tsx index 149937c9..c2e5c640 100644 --- a/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.test.tsx +++ b/apps/web/src/frontend/features/utilities/components/DatabaseAccessModal.test.tsx @@ -93,7 +93,6 @@ beforeEach(() => { permissions: [...DEFAULT_ROLE_PERMISSIONS.owner], }, status: 'ready', - localSingleUser: false, error: null, busy: false, refreshMe: vi.fn(async () => {}), diff --git a/apps/web/src/frontend/features/workflow/components/WorkflowView.test.tsx b/apps/web/src/frontend/features/workflow/components/WorkflowView.test.tsx index f8f14cf8..04eef145 100644 --- a/apps/web/src/frontend/features/workflow/components/WorkflowView.test.tsx +++ b/apps/web/src/frontend/features/workflow/components/WorkflowView.test.tsx @@ -24,7 +24,6 @@ function seedRole(role: 'viewer' | 'editor' | 'owner') { permissions: [...DEFAULT_ROLE_PERMISSIONS[role]], }, status: 'ready', - localSingleUser: false, error: null, busy: false, refreshMe: vi.fn(async () => {}), diff --git a/apps/web/src/frontend/shared/api/authApi.ts b/apps/web/src/frontend/shared/api/authApi.ts index 03248fc9..5f6847bb 100644 --- a/apps/web/src/frontend/shared/api/authApi.ts +++ b/apps/web/src/frontend/shared/api/authApi.ts @@ -6,6 +6,9 @@ import { api, type RequestOptions } from './client'; import type { AppRole, Permission, PermissionMeta } from '../lib/permissions'; +/** These routes predate the shared client and tolerate an empty reply; keep that. */ +const EMPTY_OK: RequestOptions = { allowEmpty: true }; + export interface AuthUser { id: string; email: string; @@ -22,22 +25,38 @@ export interface UserPreferences { onboardingCompleted: boolean; } -export interface AppConfig { - localSingleUser: boolean; +/** + * First-run setup state. Every install signs in; until one account can, the + * sign-in screen offers setup instead. + */ +export interface SetupState { + setupRequired: boolean; + /** The install's bound email; setup must use it. */ + setupEmail: string | null; + /** This browser is not on the server's machine: setup needs the code from the server log. */ + setupCodeRequired: boolean; } -/** These routes predate the shared client and tolerate an empty reply; keep that. */ -const EMPTY_OK: RequestOptions = { allowEmpty: true }; +const NO_SETUP: SetupState = { setupRequired: false, setupEmail: null, setupCodeRequired: false }; -/** Public SPA boot config (login required?). */ -export async function apiAppConfig(): Promise { +export async function apiSetupState(): Promise { try { - return await api.get('/config', EMPTY_OK); + return await api.get('/auth/setup', EMPTY_OK); } catch { - return { localSingleUser: true }; + return NO_SETUP; } } +/** Create (or claim) the first admin account and sign in as it. */ +export async function apiSetup(email: string, password: string, code?: string): Promise { + const { user } = await api.post<{ user: AuthUser }>( + '/auth/setup', + { email, password, ...(code ? { code } : {}) }, + EMPTY_OK + ); + return user; +} + /** Current session, or null if not signed in. */ export async function apiMe(): Promise { try { @@ -48,11 +67,6 @@ export async function apiMe(): Promise { } } -export async function apiRegister(email: string, password: string): Promise { - const { user } = await api.post<{ user: AuthUser }>('/auth/register', { email, password }, EMPTY_OK); - return user; -} - export async function apiLogin(email: string, password: string): Promise { const { user } = await api.post<{ user: AuthUser }>('/auth/login', { email, password }, EMPTY_OK); return user; @@ -85,6 +99,11 @@ export async function apiAdminListUsers(): Promise<{ return api.get('/admin/users', EMPTY_OK); } +/** Admin adds an account (there is no self-registration). */ +export async function apiAdminCreateUser(email: string, password: string, role: AppRole): Promise { + await api.post('/admin/users', { email, password, role }, EMPTY_OK); +} + export async function apiAdminSetUserRole(userId: string, role: AppRole): Promise { await api.put(`/admin/users/${encodeURIComponent(userId)}/role`, { role }, EMPTY_OK); } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index f65d5309..73d622e9 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -63,6 +63,11 @@ export default defineConfig({ // Match DEFAULT_API_PORT (3210). Override with API_PORT when needed. target: `http://localhost:${process.env.API_PORT || 3210}`, changeOrigin: true, + // Say it was forwarded. The dev server listens on every interface, and + // without these headers the API would take a browser elsewhere on the + // LAN for someone at this machine, who may run first-run setup without + // the code from the server log. + xfwd: true, // Origin is forwarded untouched, on purpose. Rewriting it to a trusted // value would switch the API's origin check off for everything that // reaches this dev server, including a DNS-rebound page (Vite serves any diff --git a/docker-compose.app.yml b/docker-compose.app.yml index 45bc8cf2..ba057b7e 100644 --- a/docker-compose.app.yml +++ b/docker-compose.app.yml @@ -34,7 +34,6 @@ services: APP_DB_URL: ${APP_DB_URL:-} APP_KEY_SCHEME: ${APP_KEY_SCHEME:-v1} LOCAL_SINGLE_USER: ${LOCAL_SINGLE_USER:-true} - AUTH_REQUIRED: ${AUTH_REQUIRED:-false} NODE_ENV: production volumes: - fox_data:/data diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 959a1822..9b5626bd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,7 +20,7 @@ foxschema stop foxschema doctor # Development (starts both the Fastify API + Vite frontend) -npm run dev # single-user mode (no login) +npm run dev # sign-in required; first open runs setup npm run dev:auth # multi-user auth mode npm run dev:with-workflow # plus workflow-server on :8081 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 2947073b..61a38655 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -14,17 +14,23 @@ foxschema shortcut Maintainers: **[PUBLISH.md](PUBLISH.md)**. -### Multi-user login + RBAC - -For team installs, require login and enable roles (`admin` / `editor` / `viewer`): - -```bash -LOCAL_SINGLE_USER=false -# AUTH_REQUIRED defaults to true when LOCAL_SINGLE_USER=false -``` - -- First UI open can still show the **email subscriber wizard** (public; before login). -- First registered account becomes **admin**; later signups default to **viewer**. +### Sign-in + RBAC + +Every install requires sign-in with an email and password — Docker, `fox open` +and the desktop app alike. Roles are `admin` / `editor` / `owner` / `viewer`. + +- **First run** shows a setup screen that creates the administrator account. On + an install that was used before sign-in was required, setup *claims* the + existing local account (email pre-filled from `fox setup` when bound), so + saved connections and history carry over. +- **Setup from another machine** (a server, a container) asks for a one-time + **setup code**, printed in the Fox server log when someone opens the setup + screen remotely. From the machine itself no code is needed. Behind a reverse + proxy every visitor counts as remote. +- **No self-registration.** Admins add people under **Profile → Access control → + Add user**, with a starting password to hand over. SSO signs in existing + accounts only. +- First UI open can still show the **email subscriber wizard** (public; before sign-in). - Admins configure role permissions and assign users under **Profile → Access control**. - Permissions cover Schema Sync (browse / compare / migrate), SQL Editor (sidebar, variables, writes, Data grid insert/update/delete, code cells), Utilities, Secrets, Access, and Workflow (`workflow.access` / `design` / `run` / `admin`). - **Database Access** catalog (`POST /schema/db-access`) is an OR gate: **Use utilities** *or* any Access-workspace permission (`access.access`, Users, builder, diff, inspector, report) may load it. GRANT / REVOKE still needs **Grant privileges**. @@ -39,7 +45,7 @@ Db2) that serves both the UI and the API on one configurable port (default **321 - [The encryption key](#the-encryption-key) - [Choosing a port](#choosing-a-port) - [Where app data lives](#where-app-data-lives) -- [Access: single-user vs. multi-user + SSO](#access-single-user-vs-multi-user--sso) +- [Access: sign-in + SSO](#access-sign-in--sso) - [Cloud platforms](#cloud-platforms) - [Database drivers](#database-drivers) - [Building the image](#building-the-image) @@ -59,7 +65,8 @@ docker run -d --name foxschema \ Open http://localhost:3210 -Defaults baked into the image: single-user mode (no login), SQLite metadata on +Defaults baked into the image: sign-in required (first open creates the admin — +read the setup code from `docker logs`), SQLite metadata on `/data`, port `3210`, **Db2 client included**. Image is **linux/amd64** only (`ibm_db` has no linux/arm64 build). Keep the same volume across upgrades so saved connections and the encryption key survive. @@ -94,8 +101,7 @@ docker compose -f docker-compose.app.yml up -d | `APP_DB_PATH` | `/data/foxschema.db` | SQLite file location (when `APP_DB_ENGINE=sqlite`). | | `APP_DB_URL` | — | Connection URL for the metadata store when engine is `postgres`/`mysql`. | | `APP_KEY_SCHEME` | `v1` | `v1` = key used directly. `v2` = key bound to `APP_USER_EMAIL` (anti-copy); leave `v1` for stateless servers. | -| `LOCAL_SINGLE_USER` | `true` | `true` = no login (open, single user). `false` = real accounts. | -| `AUTH_REQUIRED` | see note | When `LOCAL_SINGLE_USER=false`, defaults to **true** (login required). Set `AUTH_REQUIRED=false` only if you intentionally want open API access. | +| `LOCAL_SINGLE_USER` | `true` | Whether this is a personal install (`true`) or a shared server (`false`). Sign-in is required either way; this only decides machine-level actions (installing drivers, self-update, changing the metadata DB, host cloud credentials), which a shared server refuses. | | `SIGNUP_WEBHOOK_URL` | — | Optional. First-open email subscriber wizard posts here (WordPress `/foxschema/v1/signup`). Without it, subscribe still dismisses the wizard locally. | | `SIGNUP_WEBHOOK_SECRET` | — | Optional shared secret sent as `X-Foxschema-Signup-Secret`. | | `UPDATE_FEED_URL` | npm `foxschema/latest` | Version check for in-app update toasts. Default is the npm registry. The “What’s new” link opens the matching GitHub Release page. Set `off` to disable. | @@ -160,9 +166,10 @@ otherwise subscribe still dismisses locally. The upgrade migration marks the wizard as already shown, so people who already use Fox are not interrupted. Only greenfield metadata DBs see the prompt. -**RBAC upgrade:** existing `users` rows get `app_role = admin`. Default -`LOCAL_SINGLE_USER=true` still means no login. Switching later to -`LOCAL_SINGLE_USER=false` keeps those admins; new registrations become viewers. +**Sign-in upgrade:** installs that ran without sign-in open on the setup screen, +which claims the existing local account — nothing is lost. Deployments that +already ran with `LOCAL_SINGLE_USER=false` keep their accounts and passwords and +never show setup; their local account cannot be claimed. **Query files / SQL Editor:** browser localStorage (`foxschema-sql-editor`) and saved credentials keep working. New **Utilities → Query files** workspaces appear @@ -222,18 +229,18 @@ databases you compare) defaults to a SQLite file on the **`/data` volume**. Then you don't need the `/data` volume at all. -## Access: single-user vs. multi-user + SSO +## Access: sign-in + SSO -**Default is open single-user** (`LOCAL_SINGLE_USER=true`, no login). This is fine for -local use or a trusted network, **but do not expose it directly to the public internet -— anyone who can reach the URL gets full access.** Put it behind a reverse proxy with -its own authentication, a VPN, or your platform's access controls. +**Every install requires sign-in.** Until the first admin exists the server is +in setup, and anyone who completes setup owns it — which is why setup from +another machine needs the one-time code from the server log. Complete setup +before exposing a new deployment, and still keep internet-facing installs behind +TLS and, ideally, a VPN or your platform's access controls. -For a public deployment, enable real accounts + SSO: +For a shared deployment, declare it and add SSO if you use one: ```bash LOCAL_SINGLE_USER=false -AUTH_REQUIRED=true SSO_REDIRECT_BASE=https://fox.example.com # your public URL # Enable one or more providers (both ID and SECRET required per provider): @@ -247,6 +254,7 @@ SSO_MICROSOFT_TENANT=common ``` Set each provider's OAuth redirect/callback to `${SSO_REDIRECT_BASE}/api/auth/sso//callback`. +SSO proves who someone is; an admin still has to add their account first. **App Secrets / cloud credentials on multi-user hosts:** with `LOCAL_SINGLE_USER=false`, resolving AWS/GCP/Azure secrets requires a saved credential under **Credentials → Cloud diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index ac216725..841ea876 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -33,3 +33,27 @@ that capture, versions appear only for real changes. Revert and force-migrate are not affected by the boundary: when two stored versions' hashes differ, both sides are re-hashed with the current rule before deciding whether an object changed. + +## Sign-in is required on every install + +Fox used to open straight into the workspace on a personal install, and a +multi-user server let anyone register. Both are gone: **every install now asks +for an email and password**, and only an administrator can create accounts. + +- **First launch** shows *Create the administrator account*. On an install used + before, this claims the existing local account, so saved connections, history + and workflows stay where they are. When the install is bound to an email + (`APP_USER_EMAIL`), that email is used. +- **Setup code.** From the machine Fox runs on, setup needs only the password. + From anywhere else, including through a reverse proxy, setup also asks for a + one-time code that the server prints to its log (`docker logs ` on + Docker). Setup closes for good once an account can sign in. +- **No self-registration.** `POST /api/auth/register` answers 403. Admins add + people from the **Add user** form in the admin Access panel with a starting password and a role. +- **SSO signs in existing accounts only.** A first SSO sign-in no longer creates + an account; an admin adds the email first. +- **Sign-in is rate-limited** to 20 attempts per 15 minutes per client. +- A server that already ran with `LOCAL_SINGLE_USER=false` keeps its accounts and + is never offered setup. `AUTH_REQUIRED` is no longer read; `LOCAL_SINGLE_USER` + now only marks a personal install (machine-level actions such as driver + install and updates). diff --git a/packages/server/src/api/deployment.ts b/packages/server/src/api/deployment.ts index db002831..a6c07787 100644 --- a/packages/server/src/api/deployment.ts +++ b/packages/server/src/api/deployment.ts @@ -15,7 +15,13 @@ * module-load snapshot silently ignores them. */ -/** Default is single-user (no login). `LOCAL_SINGLE_USER=false` opts out. */ +/** + * A personal install (desktop app, `fox open`) rather than a shared server. + * `LOCAL_SINGLE_USER=false` declares a shared server. + * + * This no longer decides whether anyone signs in: every install does. It + * decides only which machine-level actions a signed-in user may take. + */ export function isLocalSingleUser(): boolean { return process.env.LOCAL_SINGLE_USER !== 'false'; } diff --git a/packages/server/src/api/http-contract.test.ts b/packages/server/src/api/http-contract.test.ts index 2a8a9ea2..6be7b15b 100644 --- a/packages/server/src/api/http-contract.test.ts +++ b/packages/server/src/api/http-contract.test.ts @@ -32,6 +32,10 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import type { FastifyInstance } from 'fastify'; + +// Its own metadata DB. It used to fall through to the developer's dev +// database and write preferences and signup state into it. +process.env.APP_DB_PATH = ':memory:'; import { isApiErrorBody } from '@foxschema/shared'; interface RouteExpectation { @@ -53,6 +57,7 @@ const ROUTES: RouteExpectation[] = [ { method: 'GET', path: '/api/admin/role-permissions', status: 200 }, { method: 'PUT', path: '/api/admin/role-permissions/:role', status: 400 }, { method: 'GET', path: '/api/admin/users', status: 200 }, + { method: 'POST', path: '/api/admin/users', status: 400 }, { method: 'PUT', path: '/api/admin/users/:id/active', status: 400 }, { method: 'PUT', path: '/api/admin/users/:id/password', status: 400 }, { method: 'PUT', path: '/api/admin/users/:id/role', status: 400 }, @@ -69,7 +74,9 @@ const ROUTES: RouteExpectation[] = [ { method: 'POST', path: '/api/auth/login', status: 401 }, { method: 'POST', path: '/api/auth/logout', status: 200 }, { method: 'GET', path: '/api/auth/me', status: 200 }, - { method: 'POST', path: '/api/auth/register', status: 400 }, + { method: 'POST', path: '/api/auth/register', status: 403 }, + { method: 'GET', path: '/api/auth/setup', status: 200 }, + { method: 'POST', path: '/api/auth/setup', status: 409 }, { method: 'GET', path: '/api/auth/sso/:provider/callback', status: 302 }, { method: 'GET', path: '/api/auth/sso/:provider/start', status: 404 }, { method: 'GET', path: '/api/auth/sso/providers', status: 200 }, @@ -134,6 +141,20 @@ const ROUTES: RouteExpectation[] = [ const KEY = '0'.repeat(64); +/** + * Routes that answer without a session. Everything else must refuse one: + * every install signs in, so a route missing its guard is a hole. + */ +const PUBLIC = [ + /^\/api\/health$/, + /^\/api\/config$/, + /^\/api\/auth\//, + /^\/api\/signup/, +]; + +/** The session the probes run as: the admin first-run setup creates. */ +let sessionCookie = ''; + function url(path: string): string { return path.replace(/:(\w+)/g, '00000000-0000-0000-0000-000000000000'); } @@ -153,12 +174,18 @@ interface Probe { text: string; } -async function probe(port: number, route: RouteExpectation): Promise { +async function probe(port: number, route: RouteExpectation, withSession = true): Promise { const hasBody = ['POST', 'PUT', 'PATCH'].includes(route.method); + // Auth routes run without the session: logout would otherwise end it for + // every probe after it. + const headers: Record = {}; + if (hasBody) headers['content-type'] = 'application/json'; + if (withSession && !route.path.startsWith('/api/auth/')) headers.cookie = sessionCookie; const res = await fetch(`http://127.0.0.1:${port}${url(route.path)}`, { method: route.method, redirect: 'manual', - ...(hasBody ? { headers: { 'content-type': 'application/json' }, body: '{}' } : {}), + headers, + ...(hasBody ? { body: '{}' } : {}), }); const text = await res.text(); let body: unknown = null; @@ -175,9 +202,16 @@ describe('HTTP contract', () => { let stop: () => Promise; beforeAll(async () => { - process.env.LOCAL_SINGLE_USER = 'true'; process.env.APP_ENCRYPTION_KEY ||= KEY; ({ port, stop } = await startFastify()); + // First-run setup from this machine (no code needed) creates the admin. + const res = await fetch(`http://127.0.0.1:${port}/api/auth/setup`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'contract-admin@example.com', password: 'contract-pass-1' }), + }); + expect(res.status, await res.clone().text()).toBe(200); + sessionCookie = (res.headers.get('set-cookie') ?? '').split(';')[0]!; }, 120_000); afterAll(async () => { @@ -191,7 +225,10 @@ describe('HTTP contract', () => { // // 80 -> 81: POST /api/schema/table-insight, behind dbaUtilityLimiter and // requirePermissions('editor.run') because it powers Data Peek. - expect(ROUTES.length).toBe(81); + // + // 81 -> 84: POST /api/admin/users and GET/POST /api/auth/setup, when + // sign-in became mandatory and self-registration closed. + expect(ROUTES.length).toBe(84); expect(new Set(ROUTES.map((r) => `${r.method} ${r.path}`)).size).toBe(ROUTES.length); }); @@ -233,4 +270,18 @@ describe('HTTP contract', () => { }, 30_000 ); + + it.each( + ROUTES.filter((r) => !PUBLIC.some((p) => p.test(r.path))).map( + (r) => [`${r.method} ${r.path}`, r] as const + ) + )( + '%s refuses a request with no session', + async (key, route) => { + const { status, body } = await probe(port, route, false); + expect(status, `${key} answered ${status} without a session`).toBe(401); + expect(isApiErrorBody(body)).toBe(true); + }, + 30_000 + ); }); diff --git a/packages/server/src/api/routes.ts b/packages/server/src/api/routes.ts index fa49a037..a442dc7d 100644 --- a/packages/server/src/api/routes.ts +++ b/packages/server/src/api/routes.ts @@ -167,7 +167,7 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt }); // First-open email subscriber wizard lives on public /api/signup/* (see - // signup.routes.ts) so it works before login when AUTH_REQUIRED=true. + // signup.routes.ts) so it works before sign-in. // Non-secret info about where the app's metadata DB lives and how the // credential-encryption key is bound — for the "Database & Security" settings diff --git a/packages/server/src/api/server.ts b/packages/server/src/api/server.ts index 2d397414..2a276a6e 100644 --- a/packages/server/src/api/server.ts +++ b/packages/server/src/api/server.ts @@ -18,7 +18,7 @@ * requests per user rather than per IP. */ import type { FastifyReply } from 'fastify'; -import type { AppRequest, NextFunction } from '../platform/http/types'; +import type { AppRequest } from '../platform/http/types'; import { ConnectionModule, ConnectionFactory } from '@foxschema/db'; import { AuthModule } from '../features/auth/auth.service'; import { ConnectionStore } from '../features/connections/connection-store.service'; @@ -26,7 +26,7 @@ import { sweepOrphanedUploadFiles } from '../features/files/file-session.service import { UserModule } from '../features/users/user.service'; import { createApiRoutes } from './routes'; import { defaultApiRateLimit } from '../platform/guards/rate-limit'; -import { createAuthRoutes, authGuard, localUserGuard } from '../features/auth/auth.routes'; +import { createAuthRoutes, authGuard } from '../features/auth/auth.routes'; import { createSsoRoutes } from '../features/auth/sso.routes'; import { createConnectionStoreRoutes } from '../features/connections/connections.routes'; import { createAppSecretsRoutes } from '../features/admin/app-secrets.routes'; @@ -43,11 +43,6 @@ import { resolveAppVersion } from '../internal/updates.service'; import { asAppLogger, getLogger } from '../platform/logger/logger'; import { Router, type RouteDefinition } from '../platform/http/router'; -// Default to single-user (no login). Set LOCAL_SINGLE_USER=false to enable -// multi-user auth. In multi-user mode AUTH_REQUIRED defaults to true (safe). -const LOCAL_SINGLE_USER = process.env.LOCAL_SINGLE_USER !== 'false'; -const AUTH_REQUIRED = LOCAL_SINGLE_USER ? false : process.env.AUTH_REQUIRED !== 'false'; - /** Largest request body the API accepts. Enforced by Fastify as bytes arrive. */ export const BODY_LIMIT = process.env.FOX_BODY_LIMIT || '10mb'; @@ -66,8 +61,10 @@ export function buildApiRoutes(): RouteDefinition[] { res.send({ ok: true, version: resolveAppVersion() }); }); + // Kept for clients built before sign-in became mandatory: every install now + // signs in. First-run setup state is GET /api/auth/setup. root.get('/api/config', (_req: AppRequest, res: FastifyReply) => { - res.send({ localSingleUser: LOCAL_SINGLE_USER }); + res.send({ localSingleUser: false }); }); // Auth endpoints are public. SSO is mounted first so its sub-paths take @@ -81,9 +78,10 @@ export function buildApiRoutes(): RouteDefinition[] { // service token instead. root.use(WORKFLOW_INTERNAL_PREFIX, createWorkflowInternalRoutes()); - // In local single-user mode (community desktop) the singleton local user is - // attached automatically; otherwise per-user routes require a real session. - const userGuard = LOCAL_SINGLE_USER ? localUserGuard(auth) : authGuard(auth); + // Every install requires sign-in, desktop and CLI included. There used to be + // a single-user mode that attached a built-in local user to every request; + // anyone who could reach the port was that user. + const userGuard = authGuard(auth); const connectionStore = new ConnectionStore(); root.use('/api/connections', userGuard, createConnectionStoreRoutes(connectionStore)); @@ -95,11 +93,7 @@ export function buildApiRoutes(): RouteDefinition[] { // CSV / JSON / fixed-width text → temp SQLite credential for SQL Editor. root.use('/api/files', userGuard, createFileQueryRoutes(connectionStore)); - const guard = LOCAL_SINGLE_USER - ? localUserGuard(auth) - : AUTH_REQUIRED - ? authGuard(auth) - : (_req: AppRequest, _res: FastifyReply, next: NextFunction) => next(); + const guard = userGuard; // The guard runs first so the limiter can charge an authenticated user // rather than lumping everyone behind one shared IP bucket. root.use('/api', guard, defaultApiRateLimit(), createApiRoutes(connectionModule, connectionStore)); diff --git a/packages/server/src/database/schema.ts b/packages/server/src/database/schema.ts index 488c3d67..38d677c3 100644 --- a/packages/server/src/database/schema.ts +++ b/packages/server/src/database/schema.ts @@ -452,6 +452,21 @@ const MIGRATIONS: Migration[] = [ ]; }, }, + { + id: 19, + name: 'users_password_set', + statements: (d) => { + const t = types(d); + return [ + // Whether someone chose this account's password. Sign-in is now + // required everywhere; an install that ran without it holds one local + // account whose password nobody knows, and first-run setup lets its + // owner claim it. Existing rows start at 0 and become 1 on the first + // successful sign-in, so nothing here decides who may sign in. + `ALTER TABLE users ADD COLUMN password_set ${t.int} NOT NULL DEFAULT 0`, + ]; + }, + }, ]; const SIGNUP_WIZARD_SHOWN_KEY = 'signup.wizard_shown'; diff --git a/packages/server/src/features/admin/admin.routes.ts b/packages/server/src/features/admin/admin.routes.ts index ba170597..d9d76f04 100644 --- a/packages/server/src/features/admin/admin.routes.ts +++ b/packages/server/src/features/admin/admin.routes.ts @@ -3,7 +3,7 @@ * Copyright 2024-2026 Huy Phan * SPDX-License-Identifier: Apache-2.0 * - * Admin APIs: list/assign users roles, activate/deactivate, set passwords, + * Admin APIs: add users, assign roles, activate/deactivate, set passwords, * configure role permission matrices. */ import type { FastifyReply } from 'fastify'; @@ -27,6 +27,37 @@ export function createAdminRoutes(rbac = new RbacModule(), auth = new AuthModule } ); + /** + * Add an account. The only way a second person gets in: self-registration + * is closed, and SSO signs in existing accounts only. + */ + router.post( + '/users', + requirePermissions('admin.users'), + async (req: AuthedRequest, res: FastifyReply) => { + const { email, password, role = 'viewer' } = (req.body ?? {}) as { + email?: unknown; + password?: unknown; + role?: unknown; + }; + if (typeof email !== 'string' || typeof password !== 'string') { + sendError(res, 'invalid_input', 'email and password are required.'); + return; + } + if (!isAppRole(role)) { + sendError(res, 'invalid_input', `role must be one of: ${APP_ROLES.join(', ')}`); + return; + } + try { + const user = await auth.createUser(email, password, role); + res.send({ user }); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Could not add the account'; + sendError(res, msg.includes('already exists') ? 'conflict' : 'invalid_input', msg); + } + } + ); + router.put( '/users/:id/role', requirePermissions('admin.users'), diff --git a/packages/server/src/features/admin/app-secrets.service.test.ts b/packages/server/src/features/admin/app-secrets.service.test.ts index fb04ed77..8d867d6b 100644 --- a/packages/server/src/features/admin/app-secrets.service.test.ts +++ b/packages/server/src/features/admin/app-secrets.service.test.ts @@ -15,8 +15,8 @@ let alice: string; let bob: string; beforeAll(async () => { - alice = (await auth.register('alice-secrets@example.com', 'password123')).user.id; - bob = (await auth.register('bob-secrets@example.com', 'password123')).user.id; + alice = (await auth.createUser('alice-secrets@example.com', 'password123', 'viewer')).id; + bob = (await auth.createUser('bob-secrets@example.com', 'password123', 'viewer')).id; }); describe('AppSecretsStore', () => { diff --git a/packages/server/src/features/admin/cloud-provider-credentials.service.test.ts b/packages/server/src/features/admin/cloud-provider-credentials.service.test.ts index f31f3cd5..20571202 100644 --- a/packages/server/src/features/admin/cloud-provider-credentials.service.test.ts +++ b/packages/server/src/features/admin/cloud-provider-credentials.service.test.ts @@ -13,7 +13,7 @@ const store = new CloudProviderCredentialsStore(); let alice: string; beforeAll(async () => { - alice = (await auth.register('alice-cloud-creds@example.com', 'password123')).user.id; + alice = (await auth.createUser('alice-cloud-creds@example.com', 'password123', 'viewer')).id; }); describe('CloudProviderCredentialsStore', () => { diff --git a/packages/server/src/features/auth/auth.routes.ts b/packages/server/src/features/auth/auth.routes.ts index 974f1f58..2f4cdde8 100644 --- a/packages/server/src/features/auth/auth.routes.ts +++ b/packages/server/src/features/auth/auth.routes.ts @@ -9,6 +9,9 @@ import type { AppRequest, AuthedRequest, NextFunction } from '../../platform/htt import { Router } from '../../platform/http/router'; import { AuthModule, SESSION_COOKIE, SESSION_MAX_AGE_MS, type AuthUser } from '../auth/auth.service'; import { sendError } from '../../platform/http/respond'; +import { rateLimit } from '../../platform/guards/rate-limit'; +import { getLogger } from '../../platform/logger/logger'; +import { isDirectLocalRequest, resetSetupCode, setupCode, setupCodeMatches } from './setup-code'; export type { AuthedRequest }; @@ -47,18 +50,49 @@ export function setSessionCookie(res: FastifyReply, token: string): void { export function createAuthRoutes(auth: AuthModule): Router { const router = Router(); - router.post('/register', async (req: AppRequest, res: FastifyReply) => { - const { email, password } = req.body as { email: string; password: string }; + // Sign-in is the only way in, so it is what gets guessed at. Per address, + // before any account exists to charge. + const signInLimiter = rateLimit({ name: 'sign-in', windowMs: 15 * 60 * 1000, max: 20 }); + + // No self-registration: an admin adds accounts (POST /api/admin/users). + router.post('/register', (_req: AppRequest, res: FastifyReply) => { + sendError(res, 'forbidden', 'Accounts are created by an administrator. Ask yours to add you.'); + }); + + /** + * First-run setup state. Says whether setup is still open and whether this + * caller will need the setup code, so the sign-in screen can ask for it. + */ + router.get('/setup', async (req: AppRequest, res: FastifyReply) => { + const state = await auth.setupState(); + const codeRequired = state.setupRequired && !isDirectLocalRequest(req); + if (codeRequired) announceSetupCode(); + res.send({ ...state, setupCodeRequired: codeRequired }); + }); + + router.post('/setup', signInLimiter, async (req: AppRequest, res: FastifyReply) => { + const { email, password, code } = (req.body ?? {}) as { email?: string; password?: string; code?: string }; + const state = await auth.setupState(); + if (!state.setupRequired) { + sendError(res, 'conflict', 'Setup is already complete. Sign in instead.'); + return; + } + if (!isDirectLocalRequest(req) && !setupCodeMatches(code)) { + announceSetupCode(); + sendError(res, 'forbidden', 'Enter the setup code printed in the Fox server log.'); + return; + } try { - const { user, token } = await auth.register(email, password); + const { user, token } = await auth.completeSetup(email ?? '', password ?? ''); + resetSetupCode(); setSessionCookie(res, token); res.send({ user }); } catch (error: unknown) { - sendError(res, 'invalid_input', error instanceof Error ? error.message : 'Registration failed'); + sendError(res, 'invalid_input', error instanceof Error ? error.message : 'Setup failed'); } }); - router.post('/login', async (req: AppRequest, res: FastifyReply) => { + router.post('/login', signInLimiter, async (req: AppRequest, res: FastifyReply) => { const { email, password } = req.body as { email: string; password: string }; try { const { user, token } = await auth.login(email, password); @@ -81,15 +115,9 @@ export function createAuthRoutes(auth: AuthModule): Router { res.send({ user }); return; } - // Local single-user installs have no login cookie — return the singleton - // admin so SPA boot does not 401 (which the browser logs as a console error - // and breaks e2e "no SEVERE console errors" checks). - if (process.env.LOCAL_SINGLE_USER !== 'false') { - const local = await auth.ensureLocalUser(); - res.send({ user: local }); - return; - } - sendError(res, 'unauthenticated', 'Not authenticated'); + // Nobody signed in is an answer, not an error: the app asks this on every + // boot, and a 401 here is logged by the browser as a failed request. + res.send({ user: null }); }); return router; @@ -112,18 +140,14 @@ export function authGuard(auth: AuthModule) { }; } -/** - * Local single-user guard: skips cookies/login and attaches the singleton - * local user as admin so per-user routes work without an auth flow. - */ -export function localUserGuard(auth: AuthModule) { - return async (req: AuthedRequest, _res: FastifyReply, next: NextFunction) => { - try { - const user = await auth.ensureLocalUser(); - attachAuthUser(user, req); - next(); - } catch (err) { - next(err); - } - }; +let announced = false; + +/** Print the setup code to the server log, once, when someone may need it. */ +function announceSetupCode(): void { + if (announced) return; + announced = true; + getLogger().warn( + `First-run setup: enter code ${setupCode()} on the sign-in screen to create the admin account ` + + '(only needed when setting up from another machine).' + ); } diff --git a/packages/server/src/features/auth/auth.service.test.ts b/packages/server/src/features/auth/auth.service.test.ts index 7a1c87cf..47e2f29a 100644 --- a/packages/server/src/features/auth/auth.service.test.ts +++ b/packages/server/src/features/auth/auth.service.test.ts @@ -14,41 +14,35 @@ describe('AuthModule', () => { await auth.getUserByToken('none'); }); - it('registers and auto-creates a session', async () => { - const { user, token } = await auth.register('Alice@Example.com', 'password123'); - expect(user.email).toBe('alice@example.com'); // normalized - expect(user.onboardingCompleted).toBe(false); - // First account on a fresh install is admin with full permissions. - expect(user.role).toBe('admin'); - expect(user.permissions.length).toBeGreaterThan(0); - expect((await auth.getUserByToken(token))?.id).toBe(user.id); - }); - - it('assigns viewer to subsequent registrations', async () => { - const { user } = await auth.register('viewer2@example.com', 'password123'); - expect(user.role).toBe('viewer'); - expect(user.permissions).not.toContain('admin.users'); - expect(user.permissions).toContain('editor.run'); + it('an admin-created account can sign in, with the role it was given', async () => { + const created = await auth.createUser('Alice@Example.com', 'password123', 'viewer'); + expect(created.email).toBe('alice@example.com'); // normalized + expect(created.role).toBe('viewer'); + expect(created.permissions).not.toContain('admin.users'); + expect(created.permissions).toContain('editor.run'); + const { user, token } = await auth.login('alice@example.com', 'password123'); + expect(user.id).toBe(created.id); + expect((await auth.getUserByToken(token))?.id).toBe(created.id); }); it('rejects a duplicate email', async () => { - await auth.register('dup@example.com', 'password123'); - await expect(auth.register('dup@example.com', 'password123')).rejects.toThrow(/already exists/); + await auth.createUser('dup@example.com', 'password123', 'viewer'); + await expect(auth.createUser('dup@example.com', 'password123', 'viewer')).rejects.toThrow(/already exists/); }); it('rejects weak passwords and bad emails', async () => { - await expect(auth.register('a@b.com', 'short')).rejects.toThrow(/8 characters/); - await expect(auth.register('not-an-email', 'password123')).rejects.toThrow(/valid email/); + await expect(auth.createUser('a@b.com', 'short', 'viewer')).rejects.toThrow(/8 characters/); + await expect(auth.createUser('not-an-email', 'password123', 'viewer')).rejects.toThrow(/valid email/); }); it('logs in with correct credentials', async () => { - await auth.register('bob@example.com', 'password123'); + await auth.createUser('bob@example.com', 'password123', 'viewer'); const { user } = await auth.login('bob@example.com', 'password123'); expect(user.email).toBe('bob@example.com'); }); it('rejects wrong password and unknown user the same way', async () => { - await auth.register('carol@example.com', 'password123'); + await auth.createUser('carol@example.com', 'password123', 'viewer'); await expect(auth.login('carol@example.com', 'wrongpass')).rejects.toThrow(/Invalid email or password/); await expect(auth.login('ghost@example.com', 'password123')).rejects.toThrow(/Invalid email or password/); }); @@ -57,7 +51,7 @@ describe('AuthModule', () => { // toAuthUser. Dropping that column anywhere fails silently as a demotion to // viewer, so pin the role across login and the per-request token lookup. it('preserves the stored role through login and getUserByToken', async () => { - const { user: created } = await auth.register('editorrole@example.com', 'password123'); + const created = await auth.createUser('editorrole@example.com', 'password123', 'viewer'); await new RbacModule().setUserRole(created.id, 'editor'); const { user, token } = await auth.login('editorrole@example.com', 'password123'); @@ -72,23 +66,24 @@ describe('AuthModule', () => { expect(resolved?.permissions).not.toContain('admin.users'); }); - it('ensureLocalUser returns an admin with full permissions', async () => { - const local = await auth.ensureLocalUser(); - expect(local.role).toBe('admin'); - expect(local.permissions).toContain('admin.users'); - // Idempotent: a second call re-resolves the same singleton as admin. - expect((await auth.ensureLocalUser()).id).toBe(local.id); + it('SSO signs in an existing account and never creates one', async () => { + const created = await auth.createUser('sso-user@example.com', 'password123', 'editor'); + const { user } = await auth.loginWithEmail('SSO-User@example.com'); + expect(user.id).toBe(created.id); + await expect(auth.loginWithEmail('stranger@example.com')).rejects.toThrow(/Ask an administrator/); }); it('invalidates the session on logout', async () => { - const { token } = await auth.register('dave@example.com', 'password123'); + await auth.createUser('dave@example.com', 'password123', 'viewer'); + const { token } = await auth.login('dave@example.com', 'password123'); expect(await auth.getUserByToken(token)).not.toBeNull(); await auth.logout(token); expect(await auth.getUserByToken(token)).toBeNull(); }); it('rejects login for deactivated users', async () => { - const { user, token } = await auth.register('inactive@example.com', 'password123'); + await auth.createUser('inactive@example.com', 'password123', 'viewer'); + const { user, token } = await auth.login('inactive@example.com', 'password123'); await new RbacModule().setUserActive(user.id, false); await expect(auth.login('inactive@example.com', 'password123')).rejects.toThrow( /deactivated/ @@ -97,7 +92,8 @@ describe('AuthModule', () => { }); it('adminSetPassword updates credentials and clears sessions', async () => { - const { user, token } = await auth.register('pwreset@example.com', 'password123'); + await auth.createUser('pwreset@example.com', 'password123', 'viewer'); + const { user, token } = await auth.login('pwreset@example.com', 'password123'); await auth.adminSetPassword(user.id, 'newpassword99'); expect(await auth.getUserByToken(token)).toBeNull(); await expect(auth.login('pwreset@example.com', 'password123')).rejects.toThrow( @@ -108,7 +104,7 @@ describe('AuthModule', () => { }); it('adminSetPassword rejects short passwords', async () => { - const { user } = await auth.register('pwshort@example.com', 'password123'); + const user = await auth.createUser('pwshort@example.com', 'password123', 'viewer'); await expect(auth.adminSetPassword(user.id, 'short')).rejects.toThrow(/8 characters/); }); }); diff --git a/packages/server/src/features/auth/auth.service.ts b/packages/server/src/features/auth/auth.service.ts index 32e36dcd..f4b9a755 100644 --- a/packages/server/src/features/auth/auth.service.ts +++ b/packages/server/src/features/auth/auth.service.ts @@ -7,7 +7,6 @@ import { randomUUID } from 'node:crypto'; import { getStore } from '../../database/store'; import { hashPassword, verifyPassword, newToken } from '../../platform/crypto/crypto'; import { RbacModule, toAppRole } from '../authorization/rbac.service'; -import type { MetadataStore } from '../../database/stores/types'; import type { AppRole, Permission } from '@foxschema/shared'; const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days @@ -47,10 +46,31 @@ function assertUserActive(row: UserRow): void { } } -/** First account on the install becomes admin; later signups default to viewer. */ -async function nextSignupRole(store: MetadataStore): Promise { - const countRow = await store.get<{ n: number }>('SELECT COUNT(*) AS n FROM users'); - return Number(countRow?.n ?? 0) === 0 ? 'admin' : 'viewer'; +/** The account an install that ran without sign-in stored everything under. */ +const LEGACY_LOCAL_EMAIL = 'local@foxschema.app'; + +/** The email this install is bound to (`fox setup`), if any. */ +function boundEmail(): string { + return (process.env.APP_USER_EMAIL || '').trim().toLowerCase(); +} + +/** + * Whether this deployment ran multi-user before sign-in became mandatory. + * + * Those installs already have real accounts with passwords. Their local + * account is never claimable: setup only runs there on an empty users table. + */ +function explicitMultiUser(): boolean { + return process.env.LOCAL_SINGLE_USER === 'false'; +} + +let setupChain: Promise = Promise.resolve(); + +export interface SetupState { + /** No account can sign in yet: first-run setup must create or claim one. */ + setupRequired: boolean; + /** Email the setup account must use (the install's bound email), if any. */ + setupEmail: string | null; } export class AuthModule { @@ -67,8 +87,11 @@ export class AuthModule { }; } - /** Create an account and start a session (register auto-logs-in). */ - async register(email: string, password: string): Promise<{ user: AuthUser; token: string }> { + /** + * An admin adds an account. There is no self-registration: once the first + * admin exists, people get in only when an admin adds them. + */ + async createUser(email: string, password: string, role: AppRole): Promise { validateCredentials(email, password); const store = await getStore(); const normalized = email.trim().toLowerCase(); @@ -76,19 +99,79 @@ export class AuthModule { const existing = await store.get('SELECT id FROM users WHERE email = ?', [normalized]); if (existing) throw new Error('An account with this email already exists.'); - const role = await nextSignupRole(store); const id = randomUUID(); await store.run( - 'INSERT INTO users (id, email, password_hash, created_at, app_role) VALUES (?, ?, ?, ?, ?)', + 'INSERT INTO users (id, email, password_hash, created_at, app_role, password_set) VALUES (?, ?, ?, ?, ?, 1)', [id, normalized, hashPassword(password), new Date().toISOString(), role] ); + return this.toAuthUser({ id, email: normalized, onboarding_completed: 0, app_role: role }); + } + + /** Whether first-run setup is still open, and which email it must use. */ + async setupState(): Promise { + const store = await getStore(); + const counts = await store.get<{ total: number; usable: number }>( + 'SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN password_set = 1 THEN 1 ELSE 0 END), 0) AS usable FROM users' + ); + const total = Number(counts?.total ?? 0); + const usable = Number(counts?.usable ?? 0); + const setupRequired = usable === 0 && (total === 0 || !explicitMultiUser()); + return { setupRequired, setupEmail: boundEmail() || null }; + } - const user = await this.toAuthUser({ - id, - email: normalized, - onboarding_completed: 0, - app_role: role, - }); + /** + * First-run setup: the first admin account, and a session for it. + * + * An install that ran without sign-in stored its connections and history + * under one local account; that account is claimed (given this email and + * password) rather than replaced, so nothing is left behind. Otherwise a new + * admin is created. Refused once any account can sign in. + */ + async completeSetup(email: string, password: string): Promise<{ user: AuthUser; token: string }> { + // One at a time: two requests racing through the "still open?" check would + // otherwise both succeed, and the second would own the install. + const run = setupChain.then(() => this.runSetup(email, password)); + setupChain = run.catch(() => undefined); + return run; + } + + private async runSetup(email: string, password: string): Promise<{ user: AuthUser; token: string }> { + const bound = boundEmail(); + const normalized = (bound || email || '').trim().toLowerCase(); + validateCredentials(normalized, password); + if (!(await this.setupState()).setupRequired) { + throw new Error('Setup is already complete. Sign in instead.'); + } + const store = await getStore(); + const findByEmail = (e: string) => + store.get( + 'SELECT id, email, onboarding_completed, app_role, active FROM users WHERE email = ?', + [e] + ); + const local = + (bound ? await findByEmail(bound) : undefined) ?? (await findByEmail(LEGACY_LOCAL_EMAIL)); + const clash = await findByEmail(normalized); + if (clash && (!local || clash.id !== local.id)) { + throw new Error('An account with this email already exists.'); + } + + let id: string; + let onboarded = 0; + if (local) { + id = local.id; + onboarded = local.onboarding_completed; + await store.run( + "UPDATE users SET email = ?, password_hash = ?, app_role = 'admin', active = 1, password_set = 1 WHERE id = ?", + [normalized, hashPassword(password), id] + ); + } else { + id = randomUUID(); + await store.run( + "INSERT INTO users (id, email, password_hash, created_at, app_role, password_set) VALUES (?, ?, ?, ?, 'admin', 1)", + [id, normalized, hashPassword(password), new Date().toISOString()] + ); + } + const user = await this.toAuthUser({ id, email: normalized, onboarding_completed: onboarded, app_role: 'admin' }); return { user, token: await this.createSession(id) }; } @@ -105,6 +188,8 @@ export class AuthModule { throw new Error('Invalid email or password.'); } assertUserActive(row); + // Someone knows this password, so the install is past first-run setup. + await store.run('UPDATE users SET password_set = 1 WHERE id = ? AND password_set = 0', [row.id]); return { user: await this.toAuthUser(row), token: await this.createSession(row.id) }; } @@ -119,7 +204,7 @@ export class AuthModule { const store = await getStore(); const exists = await store.get('SELECT id FROM users WHERE id = ?', [userId]); if (!exists) throw new Error('User not found.'); - await store.run('UPDATE users SET password_hash = ? WHERE id = ?', [ + await store.run('UPDATE users SET password_hash = ?, password_set = 1 WHERE id = ?', [ hashPassword(password), userId, ]); @@ -127,58 +212,51 @@ export class AuthModule { } /** - * Log in via a verified external identity (SSO): find the user by email or - * create a passwordless account, then start a session. + * Log in via a verified external identity (SSO). The identity provider + * proves who someone is; whether they may use this install is still an + * admin's decision, so only an existing account signs in. */ async loginWithEmail(email: string): Promise<{ user: AuthUser; token: string }> { const store = await getStore(); const normalized = (email ?? '').trim().toLowerCase(); if (!normalized.includes('@')) throw new Error('SSO did not return a valid email.'); - let row = await store.get( + const row = await store.get( 'SELECT id, email, onboarding_completed, app_role, active FROM users WHERE email = ?', [normalized] ); if (!row) { - const role = await nextSignupRole(store); - const id = randomUUID(); - await store.run( - 'INSERT INTO users (id, email, password_hash, created_at, app_role) VALUES (?, ?, ?, ?, ?)', - [id, normalized, hashPassword(randomUUID()), new Date().toISOString(), role] - ); - row = { id, email: normalized, onboarding_completed: 0, app_role: role, active: 1 }; + throw new Error(`No account for ${normalized}. Ask an administrator to add you.`); } assertUserActive(row); return { user: await this.toAuthUser(row), token: await this.createSession(row.id) }; } /** - * Local single-user mode: return the singleton local user (always admin). + * The install's owner, for the CLI. + * + * The CLI works on the metadata database directly, so whoever can run it + * already holds the data; it acts as the owner rather than signing in. It + * must resolve to the same account the app's first-run setup claims, or the + * CLI and the app would each see their own connections and history: the + * bound email, else the legacy local account, else the earliest admin. Only + * a brand-new install gets a fresh local account, which setup then claims. */ - async ensureLocalUser(): Promise { + async ownerAccount(): Promise { const store = await getStore(); - const boundEmail = (process.env.APP_USER_EMAIL || '').trim().toLowerCase(); - const email = boundEmail || 'local@foxschema.app'; - const find = (e: string) => - store.get( - 'SELECT id, email, onboarding_completed, app_role, active FROM users WHERE email = ?', - [e] - ); - const existing = - (await find(email)) || (boundEmail ? await find('local@foxschema.app') : undefined); - if (existing) { - if (existing.app_role !== 'admin') { - await store.run("UPDATE users SET app_role = 'admin' WHERE id = ?", [existing.id]); - } - // Local singleton stays usable even if accidentally deactivated in Access UI. - if (existing.active === 0) { - await store.run('UPDATE users SET active = 1 WHERE id = ?', [existing.id]); - } - return this.toAuthUser({ ...existing, app_role: 'admin', active: 1 }); - } + const select = 'SELECT id, email, onboarding_completed, app_role, active FROM users'; + const bound = boundEmail(); + const row = + (bound ? await store.get(`${select} WHERE email = ?`, [bound]) : undefined) ?? + (await store.get(`${select} WHERE email = ?`, [LEGACY_LOCAL_EMAIL])) ?? + (await store.get( + `${select} WHERE app_role = 'admin' AND (active IS NULL OR active = 1) ORDER BY created_at LIMIT 1` + )); + if (row) return this.toAuthUser(row); const id = randomUUID(); + const email = bound || LEGACY_LOCAL_EMAIL; await store.run( - 'INSERT INTO users (id, email, password_hash, created_at, app_role) VALUES (?, ?, ?, ?, ?)', - [id, email, hashPassword(randomUUID()), new Date().toISOString(), 'admin'] + "INSERT INTO users (id, email, password_hash, created_at, app_role) VALUES (?, ?, ?, ?, 'admin')", + [id, email, hashPassword(randomUUID()), new Date().toISOString()] ); return this.toAuthUser({ id, email, onboarding_completed: 0, app_role: 'admin' }); } diff --git a/packages/server/src/features/auth/auth.setup.test.ts b/packages/server/src/features/auth/auth.setup.test.ts new file mode 100644 index 00000000..cdcf5ee5 --- /dev/null +++ b/packages/server/src/features/auth/auth.setup.test.ts @@ -0,0 +1,125 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * First-run setup, now that every install signs in. + * + * An install that ran without sign-in kept everything under one local account + * whose password nobody knows. Setup must hand that account to its owner (so + * saved connections and history survive), create the first admin on a fresh + * install, and never open again once anyone can sign in. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +process.env.APP_DB_PATH = ':memory:'; + +import { AuthModule } from './auth.service'; +import { getStore } from '../../database/store'; + +const auth = new AuthModule(); + +async function clearUsers(): Promise { + const store = await getStore(); + await store.run('DELETE FROM sessions'); + await store.run('DELETE FROM users'); +} + +/** A row as the old single-user mode created it: random password, never set. */ +async function legacyLocalUser(email = 'local@foxschema.app'): Promise { + const store = await getStore(); + const id = `legacy-${email}`; + await store.run( + "INSERT INTO users (id, email, password_hash, created_at, app_role) VALUES (?, ?, 'x', ?, 'admin')", + [id, email, new Date().toISOString()] + ); + return id; +} + +beforeEach(async () => { + await auth.getUserByToken('none'); // run migrations + await clearUsers(); + delete process.env.APP_USER_EMAIL; + delete process.env.LOCAL_SINGLE_USER; +}); +afterEach(() => { + delete process.env.APP_USER_EMAIL; + delete process.env.LOCAL_SINGLE_USER; +}); + +describe('first-run setup', () => { + it('is required on a fresh install and creates the first admin', async () => { + expect((await auth.setupState()).setupRequired).toBe(true); + const { user, token } = await auth.completeSetup('Owner@Example.com', 'password123'); + expect(user.email).toBe('owner@example.com'); + expect(user.role).toBe('admin'); + expect((await auth.getUserByToken(token))?.id).toBe(user.id); + expect((await auth.setupState()).setupRequired).toBe(false); + }); + + it('claims the legacy local account instead of replacing it', async () => { + const legacyId = await legacyLocalUser(); + expect((await auth.setupState()).setupRequired).toBe(true); + const { user } = await auth.completeSetup('owner@example.com', 'password123'); + // Same row: everything saved under the old local account now belongs to the owner. + expect(user.id).toBe(legacyId); + expect(user.email).toBe('owner@example.com'); + const { user: signedIn } = await auth.login('owner@example.com', 'password123'); + expect(signedIn.id).toBe(legacyId); + }); + + it('uses the email the install is bound to, whatever is typed', async () => { + process.env.APP_USER_EMAIL = 'bound@example.com'; + const legacyId = await legacyLocalUser('bound@example.com'); + expect((await auth.setupState()).setupEmail).toBe('bound@example.com'); + const { user } = await auth.completeSetup('someone-else@example.com', 'password123'); + expect(user.id).toBe(legacyId); + expect(user.email).toBe('bound@example.com'); + }); + + it('closes for good once any account can sign in', async () => { + await auth.completeSetup('owner@example.com', 'password123'); + await expect(auth.completeSetup('intruder@example.com', 'password123')).rejects.toThrow( + /already complete/ + ); + }); + + it('only one of two racing setups succeeds', async () => { + const results = await Promise.allSettled([ + auth.completeSetup('first@example.com', 'password123'), + auth.completeSetup('second@example.com', 'password123'), + ]); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + }); + + it('never claims an account on a deployment that already ran multi-user', async () => { + // Real accounts with real passwords exist there; none has signed in since + // the upgrade, so password_set is still 0 for all of them. + process.env.LOCAL_SINGLE_USER = 'false'; + await legacyLocalUser('admin@corp.example'); + expect((await auth.setupState()).setupRequired).toBe(false); + await expect(auth.completeSetup('attacker@example.com', 'password123')).rejects.toThrow( + /already complete/ + ); + }); + + it('the CLI and the app resolve the same owner account, before and after setup', async () => { + const fresh = await auth.ownerAccount(); + expect(fresh.email).toBe('local@foxschema.app'); + const { user } = await auth.completeSetup('owner@example.com', 'password123'); + // Setup claimed the account the CLI created, and the CLI still finds it + // under its new email. + expect(user.id).toBe(fresh.id); + expect((await auth.ownerAccount()).id).toBe(fresh.id); + }); + + it('a successful sign-in by an existing account also closes setup', async () => { + const created = await auth.createUser('existing@example.com', 'password123', 'admin'); + const store = await getStore(); + // As after the migration: the flag starts at 0 on rows that predate it. + await store.run('UPDATE users SET password_set = 0 WHERE id = ?', [created.id]); + expect((await auth.setupState()).setupRequired).toBe(true); + await auth.login('existing@example.com', 'password123'); + expect((await auth.setupState()).setupRequired).toBe(false); + }); +}); diff --git a/packages/server/src/features/auth/setup-code.ts b/packages/server/src/features/auth/setup-code.ts new file mode 100644 index 00000000..0db033fb --- /dev/null +++ b/packages/server/src/features/auth/setup-code.ts @@ -0,0 +1,56 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Who may run first-run setup. + * + * Setup creates (or claims) the first admin account, so on a freshly started + * server whoever reaches it first owns the install. Someone at the machine is + * the owner by definition; anyone else has to prove access to the machine by + * reading a one-time code from the server's own log. + */ +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import type { AppRequest } from '../../platform/http/types'; + +let code: string | undefined; + +/** The one-time setup code for this process, generated on first use. */ +export function setupCode(): string { + if (!code) { + // 40 bits, grouped for reading off a terminal: ABCD-EFGH. + const raw = randomBytes(5).toString('hex').toUpperCase().slice(0, 8); + code = `${raw.slice(0, 4)}-${raw.slice(4)}`; + } + return code; +} + +/** Forget the code (after setup succeeds, and between tests). */ +export function resetSetupCode(): void { + code = undefined; +} + +const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); + +/** + * True when the request comes from this machine directly. + * + * The raw socket address, never `req.ip`: the server trusts proxy headers, so + * `req.ip` is whatever `X-Forwarded-For` says. And a request that carries + * forwarding headers is not treated as local even from a loopback socket: a + * reverse proxy on the same host makes every visitor look local. + */ +export function isDirectLocalRequest(req: AppRequest): boolean { + const address = req.raw?.socket?.remoteAddress ?? ''; + if (!LOOPBACK.has(address)) return false; + const headers = req.headers ?? {}; + return !headers['x-forwarded-for'] && !headers.forwarded && !headers['x-real-ip']; +} + +/** Whether `supplied` matches this process's setup code (case-insensitive). */ +export function setupCodeMatches(supplied: unknown): boolean { + if (typeof supplied !== 'string') return false; + const want = Buffer.from(setupCode()); + const got = Buffer.from(supplied.trim().toUpperCase()); + return got.length === want.length && timingSafeEqual(got, want); +} diff --git a/packages/server/src/features/auth/setup.routes.test.ts b/packages/server/src/features/auth/setup.routes.test.ts new file mode 100644 index 00000000..1c3a06fe --- /dev/null +++ b/packages/server/src/features/auth/setup.routes.test.ts @@ -0,0 +1,124 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * First-run setup and account creation over HTTP, on a real listener. + * + * Setup makes whoever completes it the admin, so the protection is the point: + * from anywhere but this machine it needs the code printed in the server log, + * a proxy header never makes a request look local, it runs once, and after it + * only an admin can let anyone else in. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +process.env.APP_DB_PATH = ':memory:'; +process.env.APP_ENCRYPTION_KEY ||= '0'.repeat(64); + +import { setupCode } from './setup-code'; + +let app: FastifyInstance; +let base = ''; + +async function call( + method: string, + path: string, + body?: unknown, + headers: Record = {} +): Promise<{ status: number; json: any; cookie: string }> { + const res = await fetch(`${base}${path}`, { + method, + headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...headers }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + const text = await res.text(); + let json: unknown = null; + try { + json = JSON.parse(text); + } catch { + /* not JSON */ + } + return { status: res.status, json, cookie: (res.headers.get('set-cookie') ?? '').split(';')[0]! }; +} + +/** Looks like it came through a reverse proxy on this host. */ +const PROXIED = { 'x-forwarded-for': '203.0.113.7' }; + +beforeAll(async () => { + const { createFastifyApp } = await import('../../api/fastify-server'); + app = await createFastifyApp({}); + await app.listen({ port: 0, host: '127.0.0.1' }); + base = `http://127.0.0.1:${(app.server.address() as { port: number }).port}/api`; +}, 120_000); + +afterAll(async () => { + await app?.close(); +}); + +describe('first-run setup over HTTP', () => { + let adminCookie = ''; + + it('nobody signed in is an answer, not an error', async () => { + const me = await call('GET', '/auth/me'); + expect(me.status).toBe(200); + expect(me.json).toEqual({ user: null }); + }); + + it('asks a proxied caller for the setup code, and not a local one', async () => { + expect((await call('GET', '/auth/setup')).json).toMatchObject({ + setupRequired: true, + setupCodeRequired: false, + }); + expect((await call('GET', '/auth/setup', undefined, PROXIED)).json).toMatchObject({ + setupRequired: true, + setupCodeRequired: true, + }); + }); + + it('refuses a proxied setup without the right code', async () => { + const creds = { email: 'owner@example.com', password: 'owner-pass-1' }; + expect((await call('POST', '/auth/setup', creds, PROXIED)).status).toBe(403); + expect((await call('POST', '/auth/setup', { ...creds, code: 'AAAA-AAAA' }, PROXIED)).status).toBe(403); + }); + + it('accepts a proxied setup with the code from the log, once', async () => { + const creds = { email: 'owner@example.com', password: 'owner-pass-1', code: setupCode().toLowerCase() }; + const done = await call('POST', '/auth/setup', creds, PROXIED); + expect(done.status).toBe(200); + expect(done.json.user.role).toBe('admin'); + adminCookie = done.cookie; + + const again = await call('POST', '/auth/setup', { email: 'late@example.com', password: 'late-pass-11' }); + expect(again.status).toBe(409); + expect((await call('GET', '/auth/setup')).json.setupRequired).toBe(false); + }); + + it('keeps self-registration closed', async () => { + const res = await call('POST', '/auth/register', { email: 'walk-in@example.com', password: 'walk-in-pass' }); + expect(res.status).toBe(403); + }); + + it('lets an admin add an account that can then sign in, and nobody else add one', async () => { + const added = await call( + 'POST', + '/admin/users', + { email: 'teammate@example.com', password: 'teammate-pass', role: 'viewer' }, + { cookie: adminCookie } + ); + expect(added.status).toBe(200); + expect(added.json.user.role).toBe('viewer'); + + const login = await call('POST', '/auth/login', { email: 'teammate@example.com', password: 'teammate-pass' }); + expect(login.status).toBe(200); + + const byViewer = await call( + 'POST', + '/admin/users', + { email: 'another@example.com', password: 'another-pass', role: 'admin' }, + { cookie: login.cookie } + ); + expect(byViewer.status).toBe(403); + expect((await call('POST', '/admin/users', { email: 'x@example.com', password: 'xxxxxxxx' })).status).toBe(401); + }); +}); diff --git a/packages/server/src/features/connections/connection-store.service.test.ts b/packages/server/src/features/connections/connection-store.service.test.ts index 8856122c..4e2deaa0 100644 --- a/packages/server/src/features/connections/connection-store.service.test.ts +++ b/packages/server/src/features/connections/connection-store.service.test.ts @@ -15,8 +15,8 @@ let alice: string; let bob: string; beforeAll(async () => { - alice = (await auth.register('alice@example.com', 'password123')).user.id; - bob = (await auth.register('bob@example.com', 'password123')).user.id; + alice = (await auth.createUser('alice@example.com', 'password123', 'viewer')).id; + bob = (await auth.createUser('bob@example.com', 'password123', 'viewer')).id; }); const sample = { diff --git a/packages/server/src/features/users/signup-wizard.routes.ts b/packages/server/src/features/users/signup-wizard.routes.ts index fbcdb47f..3b944334 100644 --- a/packages/server/src/features/users/signup-wizard.routes.ts +++ b/packages/server/src/features/users/signup-wizard.routes.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 * * Public first-open email subscriber wizard (no login required). - * Mounted before authGuard so it still appears when AUTH_REQUIRED=true. + * Mounted before authGuard so it still appears before sign-in. */ import type { FastifyReply } from 'fastify'; import type { AppRequest } from '../../platform/http/types'; diff --git a/packages/server/src/features/users/user.service.test.ts b/packages/server/src/features/users/user.service.test.ts index cd7d0dc5..28f5f075 100644 --- a/packages/server/src/features/users/user.service.test.ts +++ b/packages/server/src/features/users/user.service.test.ts @@ -11,7 +11,7 @@ const users = new UserModule(); let userId: string; beforeAll(async () => { - userId = (await auth.register('pref@example.com', 'password123')).user.id; + userId = (await auth.createUser('pref@example.com', 'password123', 'viewer')).id; }); describe('UserModule preferences', () => { diff --git a/packages/server/src/features/workflow/workflow-connection-grants.service.test.ts b/packages/server/src/features/workflow/workflow-connection-grants.service.test.ts index e4a5d988..54f71234 100644 --- a/packages/server/src/features/workflow/workflow-connection-grants.service.test.ts +++ b/packages/server/src/features/workflow/workflow-connection-grants.service.test.ts @@ -31,8 +31,8 @@ const saved = (password?: string) => ({ }); beforeAll(async () => { - alice = (await auth.register('grant-alice@example.com', 'password123')).user.id; - bob = (await auth.register('grant-bob@example.com', 'password123')).user.id; + alice = (await auth.createUser('grant-alice@example.com', 'password123', 'viewer')).id; + bob = (await auth.createUser('grant-bob@example.com', 'password123', 'viewer')).id; }); describe('WorkflowConnectionGrants', () => {