feat: issue scoped websocket session tickets

This commit is contained in:
-Puter
2026-07-13 20:19:11 +05:30
parent 8c0f8e1d57
commit eafca3aafb
7 changed files with 101 additions and 0 deletions
+1
View File
@@ -23,6 +23,7 @@ MAX_AGENT_TOKENS=4096
# Shared secret for actor → backend service calls (rotate in prod)
SERVICE_TOKEN=dev-service-token-REPLACE_ME
A2A_ALLOWED_KEY=***********
SESSION_TICKET_SECRET=
# ── Central Gitea (shared org-wide, changes.md §2A) ──
# Public URL is used for Git remotes and must be reachable by OpenCode containers.
+1
View File
@@ -98,6 +98,7 @@ services:
CLERK_SECRET_KEY: ${CLERK_SECRET_KEY}
CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY}
SERVICE_TOKEN: ${SERVICE_TOKEN:-}
SESSION_TICKET_SECRET: ${SESSION_TICKET_SECRET:?SESSION_TICKET_SECRET must be set}
A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-}
# LLM
OPENCODE_API_KEY: ${OPENCODE_API_KEY}
+1
View File
@@ -13,6 +13,7 @@
"test:curator-reconcile": "tsx scripts/curator-persisted-reconcile.test.ts",
"test:onboarding-read": "tsx scripts/onboarding-rev10-read.test.ts",
"test:user-profile": "tsx scripts/user-profile.test.ts",
"test:ws-ticket": "tsx scripts/ws-ticket.test.ts",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
+15
View File
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { signWsTicket, verifyWsTicket } from "../src/services/ws-tickets.js";
const issued = signWsTicket({ secret: "ticket-secret", audience: "interview", subject: "user-1", sessionId: "session-1", nowSeconds: 1_000 });
assert.equal(verifyWsTicket({ secret: "ticket-secret", ticket: issued.ticket, audience: "interview", sessionId: "session-1", subject: "user-1", nowSeconds: 1_001 }).sid, "session-1");
for (const invalid of [
{ audience: "roleplay" as const, sessionId: "session-1", subject: "user-1", nowSeconds: 1_001 },
{ audience: "interview" as const, sessionId: "session-2", subject: "user-1", nowSeconds: 1_001 },
{ audience: "interview" as const, sessionId: "session-1", subject: "user-2", nowSeconds: 1_001 },
{ audience: "interview" as const, sessionId: "session-1", subject: "user-1", nowSeconds: 1_061 },
]) assert.throws(() => verifyWsTicket({ secret: "ticket-secret", ticket: issued.ticket, ...invalid }));
assert.throws(() => verifyWsTicket({ secret: "ticket-secret", ticket: "not-a-ticket", audience: "interview", sessionId: "session-1", nowSeconds: 1_001 }));
assert.throws(() => verifyWsTicket({ secret: "a2a-key", ticket: issued.ticket, audience: "interview", sessionId: "session-1", subject: "user-1", nowSeconds: 1_001 }));
assert.throws(() => signWsTicket({ secret: "", audience: "interview", subject: "user-1", sessionId: "session-1" }));
console.log("ws-ticket tests passed");
+2
View File
@@ -63,6 +63,8 @@ export const config = {
serviceToken: resolveServiceToken(process.env),
// A2A is intentionally unset unless an operator configures it.
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "",
// Dedicated browser WebSocket ticket signing secret; never reuse A2A credentials.
sessionTicketSecret: process.env.SESSION_TICKET_SECRET ?? "",
// Service → backend event stream. Redis is optional; HTTP /events/ingest/service is always available.
// Explicit opt-in flags: both Redis ingestion paths are default-off. The REST
+13
View File
@@ -6,6 +6,7 @@ import { requireUser, type AuthContext } from "../auth/clerk.js";
import { config } from "../config.js";
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
import { interviewService, ProductServiceError, resumeService, roleplayService, courseService, type JsonObject } from "../services/product-service-clients.js";
import { signWsTicket } from "../services/ws-tickets.js";
import { db } from "../db/client.js";
import { events, growEvents, missionServiceSessions } from "../db/schema.js";
import { recordGrowEvent } from "../events/record-grow-event.js";
@@ -942,6 +943,12 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
});
app.get("/interview/leaderboard", async (c) => c.json(await interviewService.leaderboard()));
app.get("/interview/artifacts/:sessionId/:artifactType", async (c) => c.json(await interviewService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId"))));
app.post("/interview/sessions/:sessionId/ticket", (c) => {
if (!config.sessionTicketSecret) throw new HTTPException(503, { message: "session ticket signing is not configured" });
const sessionId = c.req.param("sessionId");
const issued = signWsTicket({ secret: config.sessionTicketSecret, audience: "interview", subject: c.get("userId"), sessionId });
return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt });
});
app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId"))));
app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
@@ -1013,6 +1020,12 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
});
app.get("/roleplay/leaderboard", async (c) => c.json(await roleplayService.leaderboard()));
app.get("/roleplay/artifacts/:sessionId/:artifactType", async (c) => c.json(await roleplayService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId"))));
app.post("/roleplay/sessions/:sessionId/ticket", (c) => {
if (!config.sessionTicketSecret) throw new HTTPException(503, { message: "session ticket signing is not configured" });
const sessionId = c.req.param("sessionId");
const issued = signWsTicket({ secret: config.sessionTicketSecret, audience: "roleplay", subject: c.get("userId"), sessionId });
return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt });
});
app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId"))));
app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
+68
View File
@@ -0,0 +1,68 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
export type WsTicketAudience = "interview" | "roleplay";
type TicketClaims = {
aud: WsTicketAudience;
sub: string;
sid: string;
iat: number;
exp: number;
nonce: string;
};
const MAX_TTL_SECONDS = 60;
function b64url(value: string | Uint8Array): string {
return Buffer.from(value).toString("base64url");
}
function decodePart(value: string): string {
return Buffer.from(value, "base64url").toString("utf8");
}
export function signWsTicket(input: {
secret: string;
audience: WsTicketAudience;
subject: string;
sessionId: string;
nowSeconds?: number;
ttlSeconds?: number;
}): { ticket: string; expiresAt: string; claims: TicketClaims } {
if (!input.secret) throw new Error("SESSION_TICKET_SECRET is not configured");
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
const ttl = Math.min(Math.max(Math.floor(input.ttlSeconds ?? MAX_TTL_SECONDS), 1), MAX_TTL_SECONDS);
const claims: TicketClaims = {
aud: input.audience,
sub: input.subject,
sid: input.sessionId,
iat: now,
exp: now + ttl,
nonce: randomBytes(16).toString("hex"),
};
const encoded = b64url(JSON.stringify(claims));
const signature = b64url(createHmac("sha256", input.secret).update(encoded).digest());
return { ticket: `${encoded}.${signature}`, expiresAt: new Date(claims.exp * 1000).toISOString(), claims };
}
export function verifyWsTicket(input: {
secret: string;
ticket: string;
audience: WsTicketAudience;
sessionId: string;
subject?: string;
nowSeconds?: number;
}): TicketClaims {
if (!input.secret) throw new Error("SESSION_TICKET_SECRET is not configured");
const parts = input.ticket.split(".");
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error("invalid ticket format");
const expected = createHmac("sha256", input.secret).update(parts[0]).digest();
const provided = Buffer.from(parts[1], "base64url");
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) throw new Error("invalid ticket signature");
let claims: TicketClaims;
try { claims = JSON.parse(decodePart(parts[0])) as TicketClaims; } catch { throw new Error("invalid ticket claims"); }
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
if (claims.aud !== input.audience || claims.sid !== input.sessionId || (input.subject && claims.sub !== input.subject)) throw new Error("ticket scope mismatch");
if (!claims.sub || !claims.nonce || !Number.isInteger(claims.iat) || !Number.isInteger(claims.exp) || claims.exp <= now || claims.exp - claims.iat > MAX_TTL_SECONDS) throw new Error("ticket expired or invalid");
return claims;
}