Skip to content

Commit eb45e2c

Browse files
authored
Merge pull request #294 from cloudflare/feat/ws-ticketing-auth
Feat: Auth via ticketing for Websocket
2 parents b7be5db + 1fe8640 commit eb45e2c

File tree

13 files changed

+588
-51
lines changed

13 files changed

+588
-51
lines changed

worker/agents/core/codingAgent.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { ProjectObjective } from "./objectives/base";
2929
import { FileOutputType } from "../schemas";
3030
import { SecretsClient, type UserSecretsStoreStub } from '../../services/secrets/SecretsClient';
3131
import { StateMigration } from './stateMigration';
32+
import { PendingWsTicket, TicketConsumptionResult } from '../../types/auth-types';
33+
import { WsTicketManager } from '../../utils/wsTicketManager';
3234

3335
const DEFAULT_CONVERSATION_SESSION_ID = 'default';
3436

@@ -43,6 +45,10 @@ export class CodeGeneratorAgent extends Agent<Env, AgentState> implements AgentI
4345
private objective!: ProjectObjective<BaseProjectState>;
4446
private secretsClient: SecretsClient | null = null;
4547
protected static readonly PROJECT_NAME_PREFIX_MAX_LENGTH = 20;
48+
49+
/** Ticket manager for WebSocket authentication */
50+
private ticketManager = new WsTicketManager();
51+
4652
// Services
4753
readonly fileManager: FileManager;
4854
readonly deploymentManager: DeploymentManager;
@@ -778,4 +784,34 @@ export class CodeGeneratorAgent extends Agent<Env, AgentState> implements AgentI
778784
clearGitHubToken(): void {
779785
this.objective.clearGitHubToken();
780786
}
787+
788+
// ==========================================
789+
// WebSocket Ticket Management
790+
// ==========================================
791+
792+
/**
793+
* Store a WebSocket ticket for later consumption
794+
* Called by controller after ownership is verified via JWT
795+
*/
796+
storeWsTicket(ticket: PendingWsTicket): void {
797+
this.ticketManager.store(ticket);
798+
this.logger().info('WebSocket ticket stored', {
799+
userId: ticket.user.id,
800+
expiresIn: Math.floor((ticket.expiresAt - Date.now()) / 1000),
801+
});
802+
}
803+
804+
/**
805+
* Consume a WebSocket ticket (one-time use)
806+
* Returns user session if valid, null otherwise
807+
*/
808+
consumeWsTicket(token: string): TicketConsumptionResult | null {
809+
const result = this.ticketManager.consume(token);
810+
if (result) {
811+
this.logger().info('Ticket consumed successfully', { userId: result.user.id });
812+
} else {
813+
this.logger().warn('Ticket consumption failed', { tokenPrefix: token.slice(0, 10) });
814+
}
815+
return result;
816+
}
781817
}

worker/api/controllers/agent/controller.ts

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { getPreviewDomain } from 'worker/utils/urls';
2222
import { ImageType, uploadImage } from 'worker/utils/images';
2323
import { ProcessedImageAttachment } from 'worker/types/image-attachment';
2424
import { getTemplateImportantFiles } from 'worker/services/sandbox/utils';
25+
import { hasTicketParam } from '../../../middleware/auth/ticketAuth';
2526

2627
const defaultCodeGenArgs: Partial<CodeGenArgs> = {
2728
language: 'typescript',
@@ -209,6 +210,10 @@ export class CodingAgentController extends BaseController {
209210
/**
210211
* Handle WebSocket connections for code generation
211212
* This routes the WebSocket connection directly to the Agent
213+
*
214+
* Supports two authentication methods:
215+
* 1. Ticket-based auth (SDK): ?ticket=tk_xxx in URL
216+
* 2. JWT-based auth (Browser): Cookie/Header with origin validation
212217
*/
213218
static async handleWebSocketConnection(
214219
request: Request,
@@ -217,52 +222,43 @@ export class CodingAgentController extends BaseController {
217222
context: RouteContext
218223
): Promise<Response> {
219224
try {
220-
const chatId = context.pathParams.agentId; // URL param is still agentId for backward compatibility
221-
if (!chatId) {
225+
const agentId = context.pathParams.agentId;
226+
if (!agentId) {
222227
return CodingAgentController.createErrorResponse('Missing agent ID parameter', 400);
223228
}
224229

225230
// Ensure the request is a WebSocket upgrade request
226231
if (request.headers.get('Upgrade') !== 'websocket') {
227232
return new Response('Expected WebSocket upgrade', { status: 426 });
228233
}
229-
230-
// Validate WebSocket origin
231-
if (!validateWebSocketOrigin(request, env)) {
232-
return new Response('Forbidden: Invalid origin', { status: 403 });
233-
}
234234

235-
// Extract user for rate limiting
236-
const user = context.user!;
235+
// User already authenticated via ticket OR JWT by middleware
236+
const user = context.user;
237237
if (!user) {
238-
return CodingAgentController.createErrorResponse('Missing user', 401);
238+
return CodingAgentController.createErrorResponse('Authentication required', 401);
239239
}
240240

241-
this.logger.info(`WebSocket connection request for chat: ${chatId}`);
242-
243-
// Log request details for debugging
244-
const headers: Record<string, string> = {};
245-
request.headers.forEach((value, key) => {
246-
headers[key] = value;
247-
});
248-
this.logger.info('WebSocket request details', {
249-
headers,
250-
url: request.url,
251-
chatId
241+
// Origin validation only for non-ticket auth (ticket auth is origin-agnostic)
242+
const isTicketAuth = hasTicketParam(request);
243+
if (!isTicketAuth && !validateWebSocketOrigin(request, env)) {
244+
return new Response('Forbidden: Invalid origin', { status: 403 });
245+
}
246+
247+
this.logger.info('WebSocket connection authorized', {
248+
agentId,
249+
userId: user.id,
250+
authMethod: isTicketAuth ? 'ticket' : 'jwt',
252251
});
253252

254253
try {
255254
// Get the agent instance to handle the WebSocket connection
256-
const agentInstance = await getAgentStub(env, chatId);
257-
258-
this.logger.info(`Successfully got agent instance for chat: ${chatId}`);
255+
const agentInstance = await getAgentStub(env, agentId);
259256

260257
// Let the agent handle the WebSocket connection directly
261258
return agentInstance.fetch(request);
262259
} catch (error) {
263-
this.logger.error(`Failed to get agent instance with ID ${chatId}:`, error);
260+
this.logger.error(`Failed to get agent instance with ID ${agentId}:`, error);
264261
// Return an appropriate WebSocket error response
265-
// We need to emulate a WebSocket response even for errors
266262
const { 0: client, 1: server } = new WebSocketPair();
267263

268264
server.accept();
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Ticket Controller
3+
*
4+
* Creates one-time-use tickets for WebSocket authentication.
5+
* Tickets are stored in the appropriate DO and consumed on connection.
6+
*/
7+
8+
import { BaseController } from '../baseController';
9+
import { RouteContext } from '../../types/route-context';
10+
import { createLogger } from '../../../logger';
11+
import { checkAppOwnership } from '../../../middleware/auth/routeAuth';
12+
import { generateTicketToken, getResourceStub } from '../../../middleware/auth/ticketAuth';
13+
import type { TicketResourceType } from '../../../middleware/auth/routeAuth';
14+
import type { PendingWsTicket, AuthUser } from '../../../types/auth-types';
15+
16+
const TICKET_TTL_MS = 15_000;
17+
18+
interface CreateTicketRequest {
19+
resourceType: TicketResourceType;
20+
resourceId?: string;
21+
}
22+
23+
// ============================================================================
24+
// Helpers
25+
// ============================================================================
26+
27+
async function verifyOwnership(
28+
user: AuthUser,
29+
resourceType: TicketResourceType,
30+
resourceId: string,
31+
env: Env
32+
): Promise<boolean> {
33+
switch (resourceType) {
34+
case 'agent':
35+
return checkAppOwnership(user, { agentId: resourceId }, env);
36+
case 'vault':
37+
return resourceId === user.id;
38+
}
39+
}
40+
41+
function resolveResourceId(
42+
body: CreateTicketRequest,
43+
userId: string
44+
): { resourceId: string } | { error: string } {
45+
if (body.resourceType === 'vault') {
46+
return { resourceId: userId };
47+
}
48+
if (!body.resourceId) {
49+
return { error: 'resourceId is required for agent tickets' };
50+
}
51+
return { resourceId: body.resourceId };
52+
}
53+
54+
// ============================================================================
55+
// Controller
56+
// ============================================================================
57+
58+
export class TicketController extends BaseController {
59+
static readonly logger = createLogger('TicketController');
60+
61+
/**
62+
* Create a WebSocket ticket
63+
* POST /api/ws-ticket
64+
*/
65+
static async createTicket(
66+
request: Request,
67+
env: Env,
68+
_ctx: ExecutionContext,
69+
context: RouteContext
70+
): Promise<Response> {
71+
const user = context.user;
72+
if (!user) {
73+
return this.createErrorResponse('Authentication required', 401);
74+
}
75+
76+
// Parse and validate request
77+
let body: CreateTicketRequest;
78+
try {
79+
body = await request.json() as CreateTicketRequest;
80+
} catch {
81+
return this.createErrorResponse('Invalid JSON body', 400);
82+
}
83+
84+
if (!body.resourceType || !['agent', 'vault'].includes(body.resourceType)) {
85+
return this.createErrorResponse('Invalid resourceType', 400);
86+
}
87+
88+
// Resolve resource ID
89+
const resolved = resolveResourceId(body, user.id);
90+
if ('error' in resolved) {
91+
return this.createErrorResponse(resolved.error, 400);
92+
}
93+
const { resourceId } = resolved;
94+
95+
// Verify ownership
96+
if (!await verifyOwnership(user, body.resourceType, resourceId, env)) {
97+
this.logger.warn('Ticket creation denied', { userId: user.id, resourceType: body.resourceType, resourceId });
98+
return this.createErrorResponse('Access denied', 403);
99+
}
100+
101+
// Create and store ticket
102+
const now = Date.now();
103+
const ticket: PendingWsTicket = {
104+
token: generateTicketToken(body.resourceType, resourceId),
105+
user,
106+
sessionId: context.sessionId ?? `ticket:${body.resourceType}:${resourceId}`,
107+
createdAt: now,
108+
expiresAt: now + TICKET_TTL_MS,
109+
};
110+
111+
try {
112+
const stub = await getResourceStub(env, body.resourceType, resourceId);
113+
await stub.storeWsTicket(ticket);
114+
115+
this.logger.info('Ticket created', { resourceType: body.resourceType, resourceId, userId: user.id });
116+
117+
return this.createSuccessResponse({
118+
ticket: ticket.token,
119+
expiresIn: Math.floor(TICKET_TTL_MS / 1000),
120+
expiresAt: new Date(ticket.expiresAt).toISOString(),
121+
});
122+
} catch (error) {
123+
this.logger.error('Failed to create ticket', { resourceType: body.resourceType, resourceId, error });
124+
return this.createErrorResponse('Failed to create ticket', 500);
125+
}
126+
}
127+
}

worker/api/routes/codegenRoutes.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ export function setupCodegenRoutes(app: Hono<AppEnv>): void {
1919
// APP EDITING ROUTES (/chat/:id frontend)
2020
// ========================================
2121

22-
// WebSocket for app editing - OWNER ONLY (for /chat/:id route)
23-
// Only the app owner should be able to connect and modify via WebSocket
24-
app.get('/api/agent/:agentId/ws', setAuthLevel(AuthConfig.ownerOnly), adaptController(CodingAgentController, CodingAgentController.handleWebSocketConnection));
22+
// WebSocket for app editing - OWNER ONLY with ticket support
23+
// Supports ticket-based auth (SDK) or JWT-based auth (browser)
24+
app.get('/api/agent/:agentId/ws', setAuthLevel(AuthConfig.ownerOnly, {
25+
ticketAuth: { resourceType: 'agent', paramName: 'agentId' }
26+
}), adaptController(CodingAgentController, CodingAgentController.handleWebSocketConnection));
2527

2628
// Connect to existing agent for editing - OWNER ONLY
2729
// Only the app owner should be able to connect for editing purposes

worker/api/routes/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { setupCodegenRoutes } from './codegenRoutes';
1111
import { setupScreenshotRoutes } from './imagesRoutes';
1212
import { setupSentryRoutes } from './sentryRoutes';
1313
import { setupCapabilitiesRoutes } from './capabilitiesRoutes';
14+
import { setupTicketRoutes } from './ticketRoutes';
1415
import { Hono } from "hono";
1516
import { AppEnv } from "../../types/appenv";
1617
import { setupStatusRoutes } from './statusRoutes';
@@ -33,6 +34,9 @@ export function setupRoutes(app: Hono<AppEnv>): void {
3334
// Authentication and user management routes
3435
setupAuthRoutes(app);
3536

37+
// WebSocket ticket routes
38+
setupTicketRoutes(app);
39+
3640
// Codegen routes
3741
setupCodegenRoutes(app);
3842

worker/api/routes/ticketRoutes.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* WebSocket Ticket Routes
3+
*/
4+
5+
import { Hono } from 'hono';
6+
import { AppEnv } from '../../types/appenv';
7+
import { AuthConfig, setAuthLevel } from '../../middleware/auth/routeAuth';
8+
import { adaptController } from '../honoAdapter';
9+
import { TicketController } from '../controllers/ticket/controller';
10+
11+
export function setupTicketRoutes(app: Hono<AppEnv>): void {
12+
// Create WebSocket ticket - requires authentication
13+
// Ownership check is done in the controller based on resourceType
14+
app.post(
15+
'/api/ws-ticket',
16+
setAuthLevel(AuthConfig.authenticated),
17+
adaptController(TicketController, TicketController.createTicket)
18+
);
19+
}

worker/api/routes/userSecretsRoutes.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ export function setupUserSecretsRoutes(app: Hono<AppEnv>): void {
1313
const vaultRouter = new Hono<AppEnv>();
1414

1515
// WebSocket connection to vault DO - must be before other routes
16+
// Supports ticket-based auth (SDK) or JWT-based auth (browser)
1617
vaultRouter.get(
1718
'/ws',
18-
setAuthLevel(AuthConfig.authenticated),
19+
setAuthLevel(AuthConfig.authenticated, {
20+
ticketAuth: { resourceType: 'vault' }
21+
}),
1922
adaptController(UserSecretsController, UserSecretsController.handleWebSocketConnection)
2023
);
2124

0 commit comments

Comments
 (0)