From 35e7f60009fc5782100065eb60fee82f1887cc37 Mon Sep 17 00:00:00 2001 From: dongwook-chan Date: Sun, 19 Jul 2026 08:23:20 +0000 Subject: [PATCH 1/2] feat(auth): add opt-in local login bypass --- .env.example | 8 +- README.md | 16 ++++ server/cli.js | 3 + server/constants/config.js | 4 +- server/index.js | 4 +- server/middleware/auth-bypass.test.js | 76 +++++++++++++++++++ server/middleware/auth.js | 26 ++++--- server/routes/auth.js | 18 ++++- server/routes/user.js | 8 ++ src/components/auth/context/AuthContext.tsx | 9 ++- src/components/auth/view/ProtectedRoute.tsx | 8 +- .../file-tree/hooks/useFileTreeUpload.ts | 4 +- src/components/shell/utils/socket.ts | 6 +- src/constants/config.ts | 4 +- src/contexts/WebSocketContext.tsx | 4 +- src/utils/api.js | 4 +- 16 files changed, 170 insertions(+), 32 deletions(-) create mode 100644 server/middleware/auth-bypass.test.js diff --git a/.env.example b/.env.example index 7e1d124c72..1fd64a7084 100755 --- a/.env.example +++ b/.env.example @@ -25,6 +25,13 @@ VITE_PORT=5173 # Use 127.0.0.1 to restrict to localhost only HOST=0.0.0.0 +# Disable CloudCLI's local login only when an upstream proxy already enforces +# authentication and the server is not directly reachable. An existing local +# user is required. Backend checks DISABLE_AUTH; Vite embeds VITE_DISABLE_AUTH +# into the frontend bundle, so set both to true before building. +# DISABLE_AUTH=true +# VITE_DISABLE_AUTH=true + # Uncomment the following line if you have a custom claude cli path other than the default "claude" # CLAUDE_CLI_PATH=claude @@ -42,4 +49,3 @@ HOST=0.0.0.0 VITE_CONTEXT_WINDOW=160000 CONTEXT_WINDOW=160000 - diff --git a/README.md b/README.md index 9fa3d66555..9cc699d118 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,22 @@ Open `http://localhost:3001` — all your existing sessions are discovered autom Visit the **[documentation →](https://cloudcli.ai/docs)** for full configuration options, PM2, remote server setup and more. +#### Authentication behind a reverse proxy + +CloudCLI requires its own local login by default. When an upstream proxy already +enforces authentication, such as Cloudflare Access, you can disable the second +login by setting both variables before building and starting CloudCLI: + +```bash +DISABLE_AUTH=true +VITE_DISABLE_AUTH=true +``` + +This mode uses the installation's existing single local user. Complete the +normal first-user setup before enabling it. Only use it when the CloudCLI server +is bound to a private address and cannot be reached around the authenticating +proxy; otherwise every direct request would be accepted without credentials. + #### Docker Sandboxes (Experimental) Run agents in isolated sandboxes with hypervisor-level isolation. Starts Claude Code by default. Requires the [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/). diff --git a/server/cli.js b/server/cli.js index 08d3af48a5..5e381a8ecc 100755 --- a/server/cli.js +++ b/server/cli.js @@ -120,6 +120,7 @@ function showStatus() { console.log(` DATABASE_PATH: ${c.dim(process.env.DATABASE_PATH || '(using default location)')}`); console.log(` CLAUDE_CLI_PATH: ${c.dim(process.env.CLAUDE_CLI_PATH || 'claude (default)')}`); console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`); + console.log(` DISABLE_AUTH: ${c.dim(process.env.DISABLE_AUTH || process.env.VITE_DISABLE_AUTH || 'false (default)')}`); // Claude projects folder const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects'); @@ -181,6 +182,8 @@ Environment Variables: DATABASE_PATH Set custom database location CLAUDE_CLI_PATH Set custom Claude CLI path CONTEXT_WINDOW Set context window size (default: 160000) + DISABLE_AUTH Trust upstream proxy authentication and bypass local login + VITE_DISABLE_AUTH Frontend build flag paired with DISABLE_AUTH Documentation: ${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'} diff --git a/server/constants/config.js b/server/constants/config.js index 580a985745..9b125892de 100644 --- a/server/constants/config.js +++ b/server/constants/config.js @@ -2,4 +2,6 @@ * Environment Flag: Is Platform * Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted) */ -export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true'; \ No newline at end of file +export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true'; +export const DISABLE_AUTH = process.env.DISABLE_AUTH === 'true' || process.env.VITE_DISABLE_AUTH === 'true'; +export const TRUST_LOCAL_AUTH_BYPASS = IS_PLATFORM || DISABLE_AUTH; diff --git a/server/index.js b/server/index.js index 0832c4905b..e6458653b7 100755 --- a/server/index.js +++ b/server/index.js @@ -67,7 +67,7 @@ import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './util import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js'; import { configureWebPush } from './services/vapid-keys.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; -import { IS_PLATFORM } from './constants/config.js'; +import { TRUST_LOCAL_AUTH_BYPASS } from './constants/config.js'; import { c } from './utils/colors.js'; const __dirname = getModuleDir(import.meta.url); @@ -105,7 +105,7 @@ const server = http.createServer(app); // Single WebSocket server that handles chat, shell, and plugin proxy paths. const wss = createWebSocketServer(server, { verifyClient: { - isPlatform: IS_PLATFORM, + isPlatform: TRUST_LOCAL_AUTH_BYPASS, authenticateWebSocket, }, chat: { diff --git a/server/middleware/auth-bypass.test.js b/server/middleware/auth-bypass.test.js new file mode 100644 index 0000000000..f07518b1fb --- /dev/null +++ b/server/middleware/auth-bypass.test.js @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +process.env.JWT_SECRET = 'test-secret'; +process.env.DISABLE_AUTH = 'true'; + +const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'cloudcli-auth-bypass-')); +process.env.DATABASE_PATH = path.join(temporaryDirectory, 'auth.db'); + +const { initializeDatabase } = await import('../modules/database/init-db.js'); +const { closeConnection } = await import('../modules/database/connection.js'); +const { userDb } = await import('../modules/database/index.js'); +const { authenticateToken, authenticateWebSocket, getLocalBypassUser } = await import('./auth.js'); +const { verifyWebSocketClient } = await import('../modules/websocket/services/websocket-auth.service.ts'); + +await initializeDatabase(); + +test.after(async () => { + closeConnection(); + await rm(temporaryDirectory, { recursive: true, force: true }); +}); + +test('authentication bypass fails closed until a local user exists', async () => { + assert.equal(getLocalBypassUser(), undefined); + assert.equal(authenticateWebSocket(null), null); + + let statusCode = null; + let responseBody = null; + await authenticateToken({}, { + status(code) { + statusCode = code; + return this; + }, + json(body) { + responseBody = body; + return this; + }, + }, () => assert.fail('middleware must not continue without a local user')); + + assert.equal(statusCode, 503); + assert.match(responseBody.error, /existing local user/i); +}); + +test('authentication bypass uses the existing single local user', async () => { + userDb.createUser('admin', 'existing-password-hash'); + + const socketUser = authenticateWebSocket(null); + assert.equal(socketUser.username, 'admin'); + + const request = {}; + let continued = false; + await authenticateToken(request, {}, () => { + continued = true; + }); + + assert.equal(continued, true); + assert.equal(request.user.username, 'admin'); +}); + +test('authentication bypass accepts WebSocket upgrades without a token', () => { + const request = { + url: '/ws', + headers: {}, + }; + + const accepted = verifyWebSocketClient({ req: request }, { + isPlatform: true, + authenticateWebSocket, + }); + + assert.equal(accepted, true); + assert.equal(request.user.username, 'admin'); +}); diff --git a/server/middleware/auth.js b/server/middleware/auth.js index c40237b2be..86ebd10a62 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -1,10 +1,12 @@ import jwt from 'jsonwebtoken'; import { userDb, appConfigDb } from '../modules/database/index.js'; -import { IS_PLATFORM } from '../constants/config.js'; +import { TRUST_LOCAL_AUTH_BYPASS } from '../constants/config.js'; // Use env var if set, otherwise auto-generate a unique secret per installation const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret(); +const getLocalBypassUser = () => userDb.getFirstUser(); + // Optional API key middleware const validateApiKey = (req, res, next) => { // Skip API key validation if not configured @@ -21,18 +23,19 @@ const validateApiKey = (req, res, next) => { // JWT authentication middleware const authenticateToken = async (req, res, next) => { - // Platform mode: use single database user - if (IS_PLATFORM) { + // Trusted deployment mode: the hosting platform or upstream proxy already + // authenticated the request, so use the installation's single local user. + if (TRUST_LOCAL_AUTH_BYPASS) { try { - const user = userDb.getFirstUser(); + const user = getLocalBypassUser(); if (!user) { - return res.status(500).json({ error: 'Platform mode: No user found in database' }); + return res.status(503).json({ error: 'Authentication bypass requires an existing local user' }); } req.user = user; return next(); } catch (error) { - console.error('Platform mode error:', error); - return res.status(500).json({ error: 'Platform mode: Failed to fetch user' }); + console.error('Authentication bypass error:', error); + return res.status(500).json({ error: 'Authentication bypass failed' }); } } @@ -90,16 +93,16 @@ const generateToken = (user) => { // WebSocket authentication function const authenticateWebSocket = (token) => { - // Platform mode: bypass token validation, return first user - if (IS_PLATFORM) { + // Trusted deployment mode: bypass token validation and use the local user. + if (TRUST_LOCAL_AUTH_BYPASS) { try { - const user = userDb.getFirstUser(); + const user = getLocalBypassUser(); if (user) { return { id: user.id, userId: user.id, username: user.username }; } return null; } catch (error) { - console.error('Platform mode WebSocket error:', error); + console.error('Authentication bypass WebSocket error:', error); return null; } } @@ -128,5 +131,6 @@ export { authenticateToken, generateToken, authenticateWebSocket, + getLocalBypassUser, JWT_SECRET }; diff --git a/server/routes/auth.js b/server/routes/auth.js index dcb2e3ff5b..c862429d96 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -2,7 +2,8 @@ import express from 'express'; import bcrypt from 'bcrypt'; import { userDb } from '../modules/database/index.js'; import { getConnection } from '../modules/database/connection.js'; -import { generateToken, authenticateToken } from '../middleware/auth.js'; +import { generateToken, authenticateToken, getLocalBypassUser } from '../middleware/auth.js'; +import { DISABLE_AUTH } from '../constants/config.js'; const router = express.Router(); const db = getConnection(); @@ -10,6 +11,21 @@ const db = getConnection(); // Check auth status and setup requirements router.get('/status', async (req, res) => { try { + if (DISABLE_AUTH) { + const user = getLocalBypassUser(); + if (!user) { + return res.status(503).json({ + error: 'DISABLE_AUTH requires an existing local user. Disable it temporarily to complete setup.' + }); + } + + return res.json({ + needsSetup: false, + isAuthenticated: true, + user + }); + } + const hasUsers = await userDb.hasUsers(); res.json({ needsSetup: !hasUsers, diff --git a/server/routes/user.js b/server/routes/user.js index 95215a8c0d..6953674da3 100644 --- a/server/routes/user.js +++ b/server/routes/user.js @@ -3,6 +3,7 @@ import express from 'express'; import spawn from 'cross-spawn'; import { userDb } from '../modules/database/index.js'; import { authenticateToken } from '../middleware/auth.js'; +import { DISABLE_AUTH } from '../constants/config.js'; import { getSystemGitConfig } from '../utils/gitConfig.js'; const router = express.Router(); @@ -108,6 +109,13 @@ router.post('/complete-onboarding', authenticateToken, async (req, res) => { router.get('/onboarding-status', authenticateToken, async (req, res) => { try { + if (DISABLE_AUTH) { + return res.json({ + success: true, + hasCompletedOnboarding: true + }); + } + const userId = req.user.id; const hasCompleted = userDb.hasCompletedOnboarding(userId); diff --git a/src/components/auth/context/AuthContext.tsx b/src/components/auth/context/AuthContext.tsx index 69bdd8a5d0..9b41354a2e 100644 --- a/src/components/auth/context/AuthContext.tsx +++ b/src/components/auth/context/AuthContext.tsx @@ -1,5 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { IS_PLATFORM } from '../../../constants/config'; +import { DISABLE_AUTH, TRUST_LOCAL_AUTH_BYPASS } from '../../../constants/config'; import { api } from '../../../utils/api'; import { AUTH_ERROR_MESSAGES, AUTH_TOKEN_STORAGE_KEY } from '../constants'; import type { @@ -116,10 +116,11 @@ export function AuthProvider({ children }: AuthProviderProps) { }, [checkOnboardingStatus, clearSession, token]); useEffect(() => { - if (IS_PLATFORM) { - setUser({ username: 'platform-user' }); + if (TRUST_LOCAL_AUTH_BYPASS) { + setUser({ username: DISABLE_AUTH ? 'local-user' : 'platform-user' }); setNeedsSetup(false); - void checkOnboardingStatus().finally(() => { + const finish = DISABLE_AUTH ? Promise.resolve() : checkOnboardingStatus(); + void finish.finally(() => { setIsLoading(false); }); return; diff --git a/src/components/auth/view/ProtectedRoute.tsx b/src/components/auth/view/ProtectedRoute.tsx index d94dcaf189..3f7eeedcd6 100644 --- a/src/components/auth/view/ProtectedRoute.tsx +++ b/src/components/auth/view/ProtectedRoute.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { IS_PLATFORM } from '../../../constants/config'; +import { DISABLE_AUTH, TRUST_LOCAL_AUTH_BYPASS } from '../../../constants/config'; import { useAuth } from '../context/AuthContext'; import Onboarding from '../../onboarding/view/Onboarding'; import AuthLoadingScreen from './AuthLoadingScreen'; @@ -17,7 +17,11 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { return ; } - if (IS_PLATFORM) { + if (TRUST_LOCAL_AUTH_BYPASS) { + if (DISABLE_AUTH) { + return <>{children}; + } + if (!hasCompletedOnboarding) { return ; } diff --git a/src/components/file-tree/hooks/useFileTreeUpload.ts b/src/components/file-tree/hooks/useFileTreeUpload.ts index dbd9370076..852d654063 100644 --- a/src/components/file-tree/hooks/useFileTreeUpload.ts +++ b/src/components/file-tree/hooks/useFileTreeUpload.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { DragEvent } from 'react'; -import { IS_PLATFORM } from '../../../constants/config'; +import { TRUST_LOCAL_AUTH_BYPASS } from '../../../constants/config'; import type { Project } from '../../../types/app'; import { isValidRefreshedToken } from '../../../utils/api'; import { @@ -116,7 +116,7 @@ const uploadFormDataWithProgress = ( xhr.open('POST', `/api/projects/${encodeURIComponent(projectId)}/files/upload`); const token = localStorage.getItem('auth-token'); - if (!IS_PLATFORM && token) { + if (!TRUST_LOCAL_AUTH_BYPASS && token) { xhr.setRequestHeader('Authorization', `Bearer ${token}`); } diff --git a/src/components/shell/utils/socket.ts b/src/components/shell/utils/socket.ts index 6cb18d6265..9f2d5d126f 100644 --- a/src/components/shell/utils/socket.ts +++ b/src/components/shell/utils/socket.ts @@ -1,10 +1,10 @@ -import { IS_PLATFORM } from '../../../constants/config'; +import { TRUST_LOCAL_AUTH_BYPASS } from '../../../constants/config'; import type { ShellIncomingMessage, ShellOutgoingMessage } from '../types/types'; export function getShellWebSocketUrl(): string | null { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - if (IS_PLATFORM) { + if (TRUST_LOCAL_AUTH_BYPASS) { return `${protocol}//${window.location.host}/shell`; } @@ -29,4 +29,4 @@ export function sendSocketMessage(ws: WebSocket | null, message: ShellOutgoingMe if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(message)); } -} \ No newline at end of file +} diff --git a/src/constants/config.ts b/src/constants/config.ts index 6aa7885b4b..39159e5ac8 100644 --- a/src/constants/config.ts +++ b/src/constants/config.ts @@ -3,6 +3,8 @@ * Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted) */ export const IS_PLATFORM = import.meta.env.VITE_IS_PLATFORM === 'true'; +export const DISABLE_AUTH = import.meta.env.VITE_DISABLE_AUTH === 'true'; +export const TRUST_LOCAL_AUTH_BYPASS = IS_PLATFORM || DISABLE_AUTH; /** * For empty shell instances where no project is provided, @@ -18,4 +20,4 @@ export const DEFAULT_PROJECT_FOR_EMPTY_SHELL = { displayName: 'default', fullPath: IS_PLATFORM ? '/workspace' : '', path: IS_PLATFORM ? '/workspace' : '', -}; \ No newline at end of file +}; diff --git a/src/contexts/WebSocketContext.tsx b/src/contexts/WebSocketContext.tsx index ef62f3fb24..2e5f0bd270 100644 --- a/src/contexts/WebSocketContext.tsx +++ b/src/contexts/WebSocketContext.tsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useAuth } from '../components/auth/context/AuthContext'; -import { IS_PLATFORM } from '../constants/config'; +import { TRUST_LOCAL_AUTH_BYPASS } from '../constants/config'; /** * One frame received from the chat websocket. The server guarantees every @@ -53,7 +53,7 @@ export const useWebSocket = () => { const buildWebSocketUrl = (token: string | null) => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - if (IS_PLATFORM) return `${protocol}//${window.location.host}/ws`; // Platform mode: Use same domain as the page (goes through proxy) + if (TRUST_LOCAL_AUTH_BYPASS) return `${protocol}//${window.location.host}/ws`; // Auth is handled by the platform or upstream proxy. if (!token) return null; return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; // OSS mode: Use same host:port that served the page }; diff --git a/src/utils/api.js b/src/utils/api.js index e5eb3fbcd4..df01d76f68 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -1,4 +1,4 @@ -import { IS_PLATFORM } from "../constants/config"; +import { TRUST_LOCAL_AUTH_BYPASS } from "../constants/config"; // Only accept a refreshed token that has this app's issued JWT shape // (three base64url segments). An attacker-injected/malformed header value @@ -22,7 +22,7 @@ export const authenticatedFetch = (url, options = {}) => { defaultHeaders['Content-Type'] = 'application/json'; } - if (!IS_PLATFORM && token) { + if (!TRUST_LOCAL_AUTH_BYPASS && token) { defaultHeaders['Authorization'] = `Bearer ${token}`; } From 85463b13d9e39b21b10d9989a0ac5c0dfba15c89 Mon Sep 17 00:00:00 2001 From: dongwook-chan Date: Sun, 19 Jul 2026 08:30:49 +0000 Subject: [PATCH 2/2] docs(auth): clarify Cloudflare Access setup --- .env.example | 9 +++++---- README.md | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 1fd64a7084..d8dece40c3 100755 --- a/.env.example +++ b/.env.example @@ -26,9 +26,11 @@ VITE_PORT=5173 HOST=0.0.0.0 # Disable CloudCLI's local login only when an upstream proxy already enforces -# authentication and the server is not directly reachable. An existing local -# user is required. Backend checks DISABLE_AUTH; Vite embeds VITE_DISABLE_AUTH -# into the frontend bundle, so set both to true before building. +# authentication (for example, Cloudflare Access with Google OAuth) and the +# server is bound privately so it cannot be reached around that proxy. The +# upstream policy must cover the entire hostname, including API and WebSocket +# routes. An existing local user is required. Backend checks DISABLE_AUTH; Vite +# embeds VITE_DISABLE_AUTH into the frontend bundle, so set both before building. # DISABLE_AUTH=true # VITE_DISABLE_AUTH=true @@ -48,4 +50,3 @@ HOST=0.0.0.0 # Claude Code context window size (maximum tokens per session) VITE_CONTEXT_WINDOW=160000 CONTEXT_WINDOW=160000 - diff --git a/README.md b/README.md index 9cc699d118..8e1a657a48 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,14 @@ Visit the **[documentation →](https://cloudcli.ai/docs)** for full configurati #### Authentication behind a reverse proxy CloudCLI requires its own local login by default. When an upstream proxy already -enforces authentication, such as Cloudflare Access, you can disable the second -login by setting both variables before building and starting CloudCLI: +enforces authentication, you can disable the second login. For example, create +a Cloudflare Access self-hosted application for the CloudCLI hostname and attach +an Access policy that allows your Google Workspace identity (or selected Google +users). Requests are then challenged by Google OAuth at Cloudflare before the +tunnel forwards them to CloudCLI. + +After the upstream login is enforced, set both variables before building and +starting CloudCLI: ```bash DISABLE_AUTH=true @@ -107,9 +113,11 @@ VITE_DISABLE_AUTH=true ``` This mode uses the installation's existing single local user. Complete the -normal first-user setup before enabling it. Only use it when the CloudCLI server -is bound to a private address and cannot be reached around the authenticating -proxy; otherwise every direct request would be accepted without credentials. +normal first-user setup before enabling it. Bind CloudCLI to `127.0.0.1` (or an +otherwise private interface), point `cloudflared` at that private origin, and do +not expose the CloudCLI port publicly. The Access application must cover the +whole hostname, including `/api`, `/ws`, and `/shell`. Otherwise a direct or +uncovered request would be accepted without CloudCLI credentials. #### Docker Sandboxes (Experimental)