diff --git a/.trajectories/active/traj_7ludwvz45veh.json b/.trajectories/active/traj_7ludwvz45veh.json index e6557bcf..307dcff0 100644 --- a/.trajectories/active/traj_7ludwvz45veh.json +++ b/.trajectories/active/traj_7ludwvz45veh.json @@ -191,6 +191,42 @@ "reasoning": "Hamburger menu visibility, logs button always visible on mobile, responsive padding throughout" }, "significance": "high" + }, + { + "ts": 1767636873294, + "type": "decision", + "content": "Starting work on bd-critical-016: Workspace Daemon Auth security fix: Starting work on bd-critical-016: Workspace Daemon Auth security fix", + "raw": { + "question": "Starting work on bd-critical-016: Workspace Daemon Auth security fix", + "chosen": "Starting work on bd-critical-016: Workspace Daemon Auth security fix", + "alternatives": [], + "reasoning": "" + }, + "significance": "high" + }, + { + "ts": 1767637700734, + "type": "decision", + "content": "Implemented workspace token auth for CLI endpoints: Implemented workspace token auth for CLI endpoints", + "raw": { + "question": "Implemented workspace token auth for CLI endpoints", + "chosen": "Implemented workspace token auth for CLI endpoints", + "alternatives": [], + "reasoning": "Added HMAC-based token validation to prevent unauthorized access to auth sessions" + }, + "significance": "high" + }, + { + "ts": 1767637829693, + "type": "decision", + "content": "bd-critical-016: Chose HMAC-SHA256 token validation over simpler approaches: bd-critical-016: Chose HMAC-SHA256 token validation over simpler approaches", + "raw": { + "question": "bd-critical-016: Chose HMAC-SHA256 token validation over simpler approaches", + "chosen": "bd-critical-016: Chose HMAC-SHA256 token validation over simpler approaches", + "alternatives": [], + "reasoning": "Matches existing provisioner token generation, cryptographically secure, no additional dependencies needed" + }, + "significance": "high" } ] } diff --git a/.trajectories/index.json b/.trajectories/index.json index 4382779e..08249638 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-01-05T17:59:19.223Z", + "lastUpdated": "2026-01-05T18:30:29.700Z", "trajectories": { "traj_ozd98si6a7ns": { "title": "Fix thinking indicator showing on all messages", diff --git a/src/cloud/api/onboarding.ts b/src/cloud/api/onboarding.ts index 74997ea5..9d96291e 100644 --- a/src/cloud/api/onboarding.ts +++ b/src/cloud/api/onboarding.ts @@ -16,6 +16,7 @@ import * as crypto from 'crypto'; import { requireAuth } from './auth.js'; import { db } from '../db/index.js'; import { vault } from '../vault/index.js'; +import { getConfig } from '../config.js'; // Import for local use import { @@ -51,6 +52,25 @@ export { export const onboardingRouter = Router(); +/** + * Generate workspace token for authenticating requests to workspace daemon. + * Must match the token generation in provisioner/index.ts + */ +function generateWorkspaceToken(workspaceId: string): string { + const config = getConfig(); + return crypto + .createHmac('sha256', config.sessionSecret) + .update(`workspace:${workspaceId}`) + .digest('hex'); +} + +/** + * Get Authorization header for workspace daemon requests + */ +function getWorkspaceAuthHeader(workspaceId: string): string { + return `Bearer ${generateWorkspaceToken(workspaceId)}`; +} + // Debug: log all requests to this router onboardingRouter.use((req, res, next) => { console.log(`[onboarding] ${req.method} ${req.path} - body:`, JSON.stringify(req.body)); @@ -80,6 +100,7 @@ interface CLIAuthSession { // Workspace delegation fields (set when auth runs in workspace daemon) workspaceUrl?: string; workspaceSessionId?: string; + workspaceId?: string; // For generating auth token } const activeSessions = new Map(); @@ -185,7 +206,10 @@ onboardingRouter.post('/cli/:provider/start', async (req: Request, res: Response const authResponse = await fetch(targetUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'Authorization': getWorkspaceAuthHeader(workspace.id), + }, body: JSON.stringify({ useDeviceFlow }), }); @@ -217,6 +241,7 @@ onboardingRouter.post('/cli/:provider/start', async (req: Request, res: Response // Store workspace info for status polling and auth code forwarding workspaceUrl, workspaceSessionId: workspaceSession.sessionId, + workspaceId: workspace.id, }; activeSessions.set(sessionId, session); @@ -253,10 +278,13 @@ onboardingRouter.get('/cli/:provider/status/:sessionId', async (req: Request, re } // If we have workspace info, poll the workspace for status - if (session.workspaceUrl && session.workspaceSessionId) { + if (session.workspaceUrl && session.workspaceSessionId && session.workspaceId) { try { const statusResponse = await fetch( - `${session.workspaceUrl}/auth/cli/${provider}/status/${session.workspaceSessionId}` + `${session.workspaceUrl}/auth/cli/${provider}/status/${session.workspaceSessionId}`, + { + headers: { 'Authorization': getWorkspaceAuthHeader(session.workspaceId) }, + } ); if (statusResponse.ok) { const workspaceStatus = await statusResponse.json() as { @@ -311,7 +339,7 @@ onboardingRouter.post('/cli/:provider/complete/:sessionId', async (req: Request, let tokenExpiresAt = session.tokenExpiresAt; // If using workspace delegation, forward complete request first - if (session.workspaceUrl && session.workspaceSessionId) { + if (session.workspaceUrl && session.workspaceSessionId && session.workspaceId) { // Forward authCode to workspace if provided (for Codex-style redirects) if (authCode) { const backendProviderId = provider === 'anthropic' ? 'anthropic' : provider; @@ -320,7 +348,10 @@ onboardingRouter.post('/cli/:provider/complete/:sessionId', async (req: Request, const completeResponse = await fetch(targetUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'Authorization': getWorkspaceAuthHeader(session.workspaceId), + }, body: JSON.stringify({ authCode }), }); @@ -337,7 +368,10 @@ onboardingRouter.post('/cli/:provider/complete/:sessionId', async (req: Request, if (!accessToken) { try { const credsResponse = await fetch( - `${session.workspaceUrl}/auth/cli/${provider}/creds/${session.workspaceSessionId}` + `${session.workspaceUrl}/auth/cli/${provider}/creds/${session.workspaceSessionId}`, + { + headers: { 'Authorization': getWorkspaceAuthHeader(session.workspaceId) }, + } ); if (credsResponse.ok) { const creds = await credsResponse.json() as { @@ -424,14 +458,17 @@ onboardingRouter.post('/cli/:provider/code/:sessionId', async (req: Request, res }); // Forward to workspace daemon - if (session.workspaceUrl && session.workspaceSessionId) { + if (session.workspaceUrl && session.workspaceSessionId && session.workspaceId) { try { const targetUrl = `${session.workspaceUrl}/auth/cli/${provider}/code/${session.workspaceSessionId}`; console.log('[onboarding] Forwarding auth code to workspace:', targetUrl); const codeResponse = await fetch(targetUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'Authorization': getWorkspaceAuthHeader(session.workspaceId), + }, body: JSON.stringify({ code }), }); @@ -485,11 +522,14 @@ onboardingRouter.post('/cli/:provider/cancel/:sessionId', async (req: Request, r const session = activeSessions.get(sessionId); if (session?.userId === userId) { // Cancel on workspace side if applicable - if (session.workspaceUrl && session.workspaceSessionId) { + if (session.workspaceUrl && session.workspaceSessionId && session.workspaceId) { try { await fetch( `${session.workspaceUrl}/auth/cli/${provider}/cancel/${session.workspaceSessionId}`, - { method: 'POST' } + { + method: 'POST', + headers: { 'Authorization': getWorkspaceAuthHeader(session.workspaceId) }, + } ); } catch { // Ignore cancel errors diff --git a/src/dashboard-server/server.ts b/src/dashboard-server/server.ts index c00a2864..d2484f2b 100644 --- a/src/dashboard-server/server.ts +++ b/src/dashboard-server/server.ts @@ -2110,6 +2110,35 @@ export async function startDashboard( // ===== CLI Auth API (for workspace-based provider authentication) ===== + /** + * Middleware to validate workspace token for internal endpoints. + * In cloud mode, requests to /auth/cli/* must include a valid WORKSPACE_TOKEN + * to prevent unauthorized access to auth sessions. + */ + const validateWorkspaceToken: express.RequestHandler = (req, res, next) => { + // Skip auth validation in local mode (no WORKSPACE_TOKEN set) + const expectedToken = process.env.WORKSPACE_TOKEN; + if (!expectedToken) { + return next(); + } + + // Extract token from Authorization header + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith('Bearer ') + ? authHeader.substring(7) + : null; + + if (!token || token !== expectedToken) { + console.warn('[dashboard] Unauthorized CLI auth request - invalid or missing workspace token'); + return res.status(401).json({ error: 'Unauthorized - invalid workspace token' }); + } + + next(); + }; + + // Apply workspace token validation to all CLI auth endpoints + app.use('/auth/cli', validateWorkspaceToken); + /** * POST /auth/cli/:provider/start - Start CLI auth flow * Body: { useDeviceFlow?: boolean }