feat: issue scoped websocket session tickets
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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"))));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user