diff --git a/bun.lock b/bun.lock index 1f439360..69e9102b 100644 --- a/bun.lock +++ b/bun.lock @@ -261,6 +261,7 @@ "express": "^5.1.0", "ghostty-web": "0.3.0", "http-proxy-middleware": "^3.0.5", + "jose": "^6.1.3", "jsonc-parser": "^3.3.1", "next-themes": "^0.4.6", "node-pty": "^1.1.0", @@ -2176,6 +2177,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -3224,6 +3227,8 @@ "@openchamber/ui/ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="], + "@openchamber/web/@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.10", "", {}, "sha512-SyXcVqry2hitPVvQtvXOhqsWyFhSycG/+LTLYXrcq8AFmd9FR7dyBSDB3f5Ol6IPkYOegk8P2Eg2kKPNSNiKGw=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 0f87beeb..45eea891 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -10,17 +10,21 @@ import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitc const STATUS_CHECK_ENDPOINT = '/auth/session'; const fetchSessionStatus = async (): Promise => { - return fetch(STATUS_CHECK_ENDPOINT, { + console.log('[Frontend Auth] Checking session status...'); + const response = await fetch(STATUS_CHECK_ENDPOINT, { method: 'GET', credentials: 'include', headers: { Accept: 'application/json', }, }); + console.log('[Frontend Auth] Session status response:', response.status, response.statusText); + return response; }; const submitPassword = async (password: string): Promise => { - return fetch(STATUS_CHECK_ENDPOINT, { + console.log('[Frontend Auth] Submitting password...'); + const response = await fetch(STATUS_CHECK_ENDPOINT, { method: 'POST', credentials: 'include', headers: { @@ -29,6 +33,8 @@ const submitPassword = async (password: string): Promise => { }, body: JSON.stringify({ password }), }); + console.log('[Frontend Auth] Password submit response:', response.status, response.statusText); + return response; }; const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => ( @@ -134,30 +140,58 @@ export const SessionAuthGate: React.FC = ({ children }) => const checkStatus = React.useCallback(async () => { if (skipAuth) { + console.log('[Frontend Auth] VSCode runtime, skipping auth'); setState('authenticated'); return; } + // 检查 cookie 是否存在 + const cookies = document.cookie; + const hasAccessToken = cookies.includes('oc_ui_session='); + const hasRefreshToken = cookies.includes('oc_ui_refresh='); + console.log('[Frontend Auth] Cookies check - access:', hasAccessToken, 'refresh:', hasRefreshToken); + console.log('[Frontend Auth] All cookies:', cookies.split(';').map(c => c.trim().split('=')[0])); + setState((prev) => (prev === 'authenticated' ? prev : 'pending')); try { const response = await fetchSessionStatus(); + const responseText = await response.text(); + console.log('[Frontend Auth] Raw response:', response.status, responseText); + if (response.ok) { + console.log('[Frontend Auth] Session is authenticated'); setState('authenticated'); setErrorMessage(''); setRetryAfter(undefined); return; } if (response.status === 401) { + let data: { debug?: { hasRefreshToken: boolean; message: string } } = {}; + try { + data = JSON.parse(responseText); + } catch { + data = {}; + } + console.warn('[Frontend Auth] Session is locked (401)', data); + if (data.debug) { + console.warn('[Frontend Auth] Debug info:', data.debug); + } setState('locked'); setRetryAfter(undefined); return; } if (response.status === 429) { - const data = await response.json().catch(() => ({})); + let data: { retryAfter?: number } = {}; + try { + data = JSON.parse(responseText); + } catch { + data = {}; + } setRetryAfter(data.retryAfter); setState('rate-limited'); return; } + console.error('[Frontend Auth] Unexpected response status:', response.status); setState('error'); } catch (error) { console.warn('Failed to check session status:', error); @@ -254,24 +288,34 @@ export const SessionAuthGate: React.FC = ({ children }) => try { const response = await submitPassword(password); if (response.ok) { + console.log('[Frontend Auth] Login successful'); + // 检查登录后 cookie 是否被设置 + const cookies = document.cookie; + const hasAccessToken = cookies.includes('oc_ui_session='); + const hasRefreshToken = cookies.includes('oc_ui_refresh='); + console.log('[Frontend Auth] After login - access:', hasAccessToken, 'refresh:', hasRefreshToken); + console.log('[Frontend Auth] All cookies after login:', cookies.split(';').map(c => c.trim().split('=')[0]).filter(Boolean)); setPassword(''); setState('authenticated'); return; } if (response.status === 401) { + console.warn('[Frontend Auth] Login failed: Invalid password'); setErrorMessage('Incorrect password. Try again.'); setState('locked'); return; } if (response.status === 429) { + console.warn('[Frontend Auth] Login failed: Rate limited'); const data = await response.json().catch(() => ({})); setRetryAfter(data.retryAfter); setState('rate-limited'); return; } + console.error('[Frontend Auth] Login failed: Unexpected response', response.status); setErrorMessage('Unexpected response from server.'); setState('error'); } catch (error) { diff --git a/packages/ui/src/hooks/useServerSessionStatus.ts b/packages/ui/src/hooks/useServerSessionStatus.ts index 87417dd2..54e4db5e 100644 --- a/packages/ui/src/hooks/useServerSessionStatus.ts +++ b/packages/ui/src/hooks/useServerSessionStatus.ts @@ -78,6 +78,10 @@ export function useServerSessionStatus() { headers: { Accept: 'application/json' }, }).then(async (r) => { if (!r.ok) { + console.warn('[useServerSessionStatus] API returned', r.status); + if (r.status === 401) { + console.warn('[useServerSessionStatus] Authentication required - session may have expired'); + } throw new Error(String(r.status)); } return (await r.json()) as ServerSnapshotResponse; diff --git a/packages/web/package.json b/packages/web/package.json index 3e7ebb60..12433939 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -48,6 +48,7 @@ "express": "^5.1.0", "ghostty-web": "0.3.0", "http-proxy-middleware": "^3.0.5", + "jose": "^6.1.3", "jsonc-parser": "^3.3.1", "next-themes": "^0.4.6", "node-pty": "^1.1.0", diff --git a/packages/web/server/index.js b/packages/web/server/index.js index e6a9bb09..c5ee62e5 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -5713,10 +5713,22 @@ async function main(options = {}) { console.log('UI password protection enabled for browser sessions'); } - app.get('/auth/session', (req, res) => uiAuthController.handleSessionStatus(req, res)); + app.get('/auth/session', async (req, res) => { + try { + await uiAuthController.handleSessionStatus(req, res); + } catch (err) { + res.status(500).json({ error: 'Internal server error' }); + } + }); app.post('/auth/session', (req, res) => uiAuthController.handleSessionCreate(req, res)); - app.use('/api', (req, res, next) => uiAuthController.requireAuth(req, res, next)); + app.use('/api', async (req, res, next) => { + try { + await uiAuthController.requireAuth(req, res, next); + } catch (err) { + next(err); + } + }); const parsePushSubscribeBody = (body) => { if (!body || typeof body !== 'object') return null; @@ -5757,7 +5769,7 @@ async function main(options = {}) { await ensurePushInitialized(); const uiToken = uiAuthController?.ensureSessionToken - ? uiAuthController.ensureSessionToken(req, res) + ? await uiAuthController.ensureSessionToken(req, res) : getUiSessionTokenFromRequest(req); if (!uiToken) { return res.status(401).json({ error: 'UI session missing' }); @@ -5805,7 +5817,7 @@ async function main(options = {}) { await ensurePushInitialized(); const uiToken = uiAuthController?.ensureSessionToken - ? uiAuthController.ensureSessionToken(req, res) + ? await uiAuthController.ensureSessionToken(req, res) : getUiSessionTokenFromRequest(req); if (!uiToken) { return res.status(401).json({ error: 'UI session missing' }); @@ -5820,9 +5832,9 @@ async function main(options = {}) { res.json({ ok: true }); }); - app.post('/api/push/visibility', (req, res) => { + app.post('/api/push/visibility', async (req, res) => { const uiToken = uiAuthController?.ensureSessionToken - ? uiAuthController.ensureSessionToken(req, res) + ? await uiAuthController.ensureSessionToken(req, res) : getUiSessionTokenFromRequest(req); if (!uiToken) { return res.status(401).json({ error: 'UI session missing' }); diff --git a/packages/web/server/lib/opencode/ui-auth.js b/packages/web/server/lib/opencode/ui-auth.js index 2e1f2477..7349f438 100644 --- a/packages/web/server/lib/opencode/ui-auth.js +++ b/packages/web/server/lib/opencode/ui-auth.js @@ -1,8 +1,11 @@ import crypto from 'crypto'; +import { SignJWT, jwtVerify } from 'jose'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; const SESSION_COOKIE_NAME = 'oc_ui_session'; const SESSION_TTL_MS = 12 * 60 * 60 * 1000; -const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000; const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS) || 10; @@ -271,6 +274,37 @@ const normalizePassword = (candidate) => { return candidate.normalize().trim(); }; +const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber'); +const JWT_SECRET_FILE = path.join(OPENCHAMBER_DATA_DIR, 'jwt-secret'); + +function getOrCreateJwtSecret() { + const envSecret = process.env.OPENCODE_JWT_SECRET; + if (envSecret) { + return new TextEncoder().encode(envSecret); + } + + try { + if (fs.existsSync(JWT_SECRET_FILE)) { + return new TextEncoder().encode(fs.readFileSync(JWT_SECRET_FILE, 'utf8').trim()); + } + } catch (e) { + console.warn('[JWT] Failed to read secret file:', e.message); + } + + const secret = crypto.randomBytes(32).toString('hex'); + try { + fs.mkdirSync(OPENCHAMBER_DATA_DIR, { recursive: true }); + fs.writeFileSync(JWT_SECRET_FILE, secret, { mode: 0o600 }); + console.log('[JWT] Generated and persisted new secret to', JWT_SECRET_FILE); + } catch (e) { + console.warn('[JWT] Failed to persist secret:', e.message); + } + + return new TextEncoder().encode(secret); +} + export const createUiAuth = ({ password, cookieName = SESSION_COOKIE_NAME, @@ -291,7 +325,7 @@ export const createUiAuth = ({ res.setHeader('Set-Cookie', header); }; - const ensureSessionToken = (req, res) => { + const ensureSessionToken = async (req, res) => { const cookies = parseCookies(req.headers.cookie); if (cookies[cookieName]) { return cookies[cookieName]; @@ -319,9 +353,7 @@ export const createUiAuth = ({ const salt = crypto.randomBytes(16); const expectedHash = crypto.scryptSync(normalizedPassword, salt, 64); - const sessions = new Map(); - - let cleanupTimer = null; + const JWT_SECRET = getOrCreateJwtSecret(); const getTokenFromRequest = (req) => { const cookies = parseCookies(req.headers.cookie); @@ -331,12 +363,6 @@ export const createUiAuth = ({ return null; }; - const dropSession = (token) => { - if (token) { - sessions.delete(token); - } - }; - const setSessionCookie = (req, res, token) => { const secure = isSecureRequest(req); const maxAgeSeconds = Math.floor(sessionTtlMs / 1000); @@ -376,49 +402,28 @@ export const createUiAuth = ({ } }; - const isSessionValid = (token) => { + const isSessionValid = async (token) => { if (!token) { return false; } - const record = sessions.get(token); - if (!record) { + try { + await jwtVerify(token, JWT_SECRET); + return true; + } catch { return false; } - if (Date.now() - record.lastSeen > sessionTtlMs) { - sessions.delete(token); - return false; - } - record.lastSeen = Date.now(); - return true; }; - const issueSession = (req, res) => { - const token = crypto.randomBytes(32).toString('base64url'); - const now = Date.now(); - sessions.set(token, { createdAt: now, lastSeen: now }); + const issueSession = async (req, res) => { + const token = await new SignJWT({ type: 'ui-session' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(sessionTtlMs / 1000 + 's') + .sign(JWT_SECRET); setSessionCookie(req, res, token); return token; }; - const cleanupStaleSessions = () => { - const now = Date.now(); - for (const [token, record] of sessions.entries()) { - if (now - record.lastSeen > sessionTtlMs) { - sessions.delete(token); - } - } - }; - - const startCleanup = () => { - if (!cleanupTimer) { - cleanupTimer = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS); - if (cleanupTimer && typeof cleanupTimer.unref === 'function') { - cleanupTimer.unref(); - } - } - }; - - startCleanup(); startRateLimitCleanup(); const respondUnauthorized = (req, res) => { @@ -431,21 +436,21 @@ export const createUiAuth = ({ } }; - const requireAuth = (req, res, next) => { + const requireAuth = async (req, res, next) => { if (req.method === 'OPTIONS') { return next(); } const token = getTokenFromRequest(req); - if (isSessionValid(token)) { + if (await isSessionValid(token)) { return next(); } clearSessionCookie(req, res); return respondUnauthorized(req, res); }; - const handleSessionStatus = (req, res) => { + const handleSessionStatus = async (req, res) => { const token = getTokenFromRequest(req); - if (isSessionValid(token)) { + if (await isSessionValid(token)) { res.json({ authenticated: true }); return; } @@ -479,22 +484,12 @@ export const createUiAuth = ({ await clearRateLimit(req); - const previousToken = getTokenFromRequest(req); - if (previousToken) { - dropSession(previousToken); - } - - issueSession(req, res); + await issueSession(req, res); res.json({ authenticated: true }); }; const dispose = () => { - sessions.clear(); loginRateLimiter.clear(); - if (cleanupTimer) { - clearInterval(cleanupTimer); - cleanupTimer = null; - } if (rateLimitCleanupTimer) { clearInterval(rateLimitCleanupTimer); rateLimitCleanupTimer = null; @@ -506,9 +501,9 @@ export const createUiAuth = ({ requireAuth, handleSessionStatus, handleSessionCreate, - ensureSessionToken: (req, _res) => { + ensureSessionToken: async (req, _res) => { const token = getTokenFromRequest(req); - return isSessionValid(token) ? token : null; + return (await isSessionValid(token)) ? token : null; }, dispose, };