This commit is contained in:
122
convex/voice.ts
122
convex/voice.ts
@@ -1,12 +1,48 @@
|
||||
"use node";
|
||||
|
||||
import { action } from "./_generated/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import { v } from "convex/values";
|
||||
import { AccessToken, RoomServiceClient } from "livekit-server-sdk";
|
||||
import crypto from "crypto";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
|
||||
// Shapes of the internal queries below, spelled out so voice.ts can typecheck
|
||||
// without depending on `internal.auth.*` inference — otherwise the codegen
|
||||
// cycle (voice exports -> _generated/api -> internal.auth typing -> voice
|
||||
// usage) drops back to `any` and TS7022/TS7023 fire on the whole action.
|
||||
type VoiceTokenUser = {
|
||||
userId: Id<"userProfiles">;
|
||||
username: string;
|
||||
publicSigningKey: string;
|
||||
};
|
||||
type VoiceTokenChannel = {
|
||||
channelId: Id<"channels">;
|
||||
type: string;
|
||||
};
|
||||
type VoiceTokenResult = { token: string } | { error: string };
|
||||
|
||||
// Signed message the client must sign with their Ed25519 private signing key
|
||||
// to prove they control the userId they're asking a token for. `timestamp`
|
||||
// defeats replay; the channelId binds the signature to a specific room.
|
||||
function buildVoiceTokenMessage(
|
||||
userId: string,
|
||||
channelId: string,
|
||||
timestamp: number,
|
||||
): string {
|
||||
return `voice-token:${userId}:${channelId}:${timestamp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a LiveKit join token for a voice channel.
|
||||
*
|
||||
* Authorization: caller signs `voice-token:userId:channelId:timestamp` with
|
||||
* their Ed25519 signing key. The server verifies against the userProfile's
|
||||
* `publicSigningKey`, rejects stale timestamps, and confirms the channel
|
||||
* exists and is a voice/dm room. The LiveKit identity is pinned to the
|
||||
* server-resolved user so clients can't impersonate each other even with a
|
||||
* valid signature for their own account.
|
||||
*
|
||||
* LiveKit servers run with `room.auto_create: false` reject joins for
|
||||
* rooms that don't already exist — the client gets a 404 "requested
|
||||
* room does not exist" back from the /rtc/v1/validate endpoint. To
|
||||
@@ -22,12 +58,65 @@ import { AccessToken, RoomServiceClient } from "livekit-server-sdk";
|
||||
*/
|
||||
export const getToken = action({
|
||||
args: {
|
||||
channelId: v.string(),
|
||||
userId: v.string(),
|
||||
username: v.string(),
|
||||
channelId: v.id("channels"),
|
||||
userId: v.id("userProfiles"),
|
||||
timestamp: v.number(),
|
||||
signature: v.string(),
|
||||
},
|
||||
returns: v.object({ token: v.string() }),
|
||||
handler: async (_ctx, args) => {
|
||||
returns: v.union(
|
||||
v.object({ token: v.string() }),
|
||||
v.object({ error: v.string() }),
|
||||
),
|
||||
handler: async (ctx, args): Promise<VoiceTokenResult> => {
|
||||
// Reject timestamps outside a 5-minute window on either side of the
|
||||
// server clock. Matches the recovery action's window and keeps replay
|
||||
// attempts short-lived even if a signature leaks.
|
||||
const now = Date.now();
|
||||
if (!Number.isFinite(args.timestamp) || Math.abs(now - args.timestamp) > 5 * 60 * 1000) {
|
||||
return { error: "Request expired. Please try again." };
|
||||
}
|
||||
|
||||
const user: VoiceTokenUser | null = await ctx.runQuery(
|
||||
internal.auth.getUserForVoiceToken,
|
||||
{ userId: args.userId },
|
||||
);
|
||||
if (!user) {
|
||||
return { error: "User not found" };
|
||||
}
|
||||
|
||||
const channel: VoiceTokenChannel | null = await ctx.runQuery(
|
||||
internal.auth.getChannelForVoiceToken,
|
||||
{ channelId: args.channelId },
|
||||
);
|
||||
if (!channel) {
|
||||
return { error: "Channel not found" };
|
||||
}
|
||||
if (channel.type !== "voice" && channel.type !== "dm") {
|
||||
return { error: "Not a voice channel" };
|
||||
}
|
||||
|
||||
// Verify the caller actually controls `userId` by checking their
|
||||
// signature over (userId, channelId, timestamp).
|
||||
const message = buildVoiceTokenMessage(user.userId, channel.channelId, args.timestamp);
|
||||
try {
|
||||
const publicKeyObj = crypto.createPublicKey({
|
||||
key: user.publicSigningKey,
|
||||
format: "pem",
|
||||
type: "spki",
|
||||
});
|
||||
const isValid = crypto.verify(
|
||||
null,
|
||||
Buffer.from(message),
|
||||
publicKeyObj,
|
||||
Buffer.from(args.signature, "hex"),
|
||||
);
|
||||
if (!isValid) {
|
||||
return { error: "Invalid signature" };
|
||||
}
|
||||
} catch {
|
||||
return { error: "Signature verification failed" };
|
||||
}
|
||||
|
||||
const apiKey = process.env.LIVEKIT_API_KEY || "devkey";
|
||||
const apiSecret = process.env.LIVEKIT_API_SECRET || "secret";
|
||||
const livekitUrl =
|
||||
@@ -43,7 +132,7 @@ export const getToken = action({
|
||||
try {
|
||||
const roomService = new RoomServiceClient(httpUrl, apiKey, apiSecret);
|
||||
await roomService.createRoom({
|
||||
name: args.channelId,
|
||||
name: channel.channelId,
|
||||
// Empty rooms auto-destroy after 5 minutes with no participants,
|
||||
// matching LiveKit's own default so stale rooms from a crashed
|
||||
// client don't pile up forever.
|
||||
@@ -56,36 +145,39 @@ export const getToken = action({
|
||||
} catch (err: any) {
|
||||
// 409 / "already exists" is expected when a room has already
|
||||
// been created by an earlier join — swallow it and continue.
|
||||
const message = String(err?.message ?? err ?? "");
|
||||
const errMsg = String(err?.message ?? err ?? "");
|
||||
const status = err?.status ?? err?.statusCode;
|
||||
const alreadyExists =
|
||||
status === 409 ||
|
||||
/already exists/i.test(message) ||
|
||||
/AlreadyExists/i.test(message);
|
||||
/already exists/i.test(errMsg) ||
|
||||
/AlreadyExists/i.test(errMsg);
|
||||
if (!alreadyExists) {
|
||||
// Non-fatal: log and fall through to token generation. If the
|
||||
// real issue was misconfiguration the client will surface the
|
||||
// 404 it already does.
|
||||
console.warn("LiveKit createRoom failed:", message);
|
||||
console.warn("LiveKit createRoom failed:", errMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const at = new AccessToken(apiKey, apiSecret, {
|
||||
identity: args.userId,
|
||||
name: args.username,
|
||||
// Pin identity + name to the server-resolved user. A forged `username`
|
||||
// or `userId` in the args would have already been rejected by the
|
||||
// signature check, but using the DB values is defence-in-depth.
|
||||
const at: AccessToken = new AccessToken(apiKey, apiSecret, {
|
||||
identity: user.userId,
|
||||
name: user.username,
|
||||
ttl: "24h",
|
||||
});
|
||||
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
room: args.channelId,
|
||||
room: channel.channelId,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishData: true,
|
||||
});
|
||||
|
||||
const token = await at.toJwt();
|
||||
const token: string = await at.toJwt();
|
||||
return { token };
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user