Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add user verification for server API #37

Closed
wants to merge 14 commits into from
32 changes: 21 additions & 11 deletions client/src/components/account/admin.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
import { getStreamKey, resetRoom } from "../../lib/server-api";
import { useEffect, useState } from "react";

import { app } from "../../lib/firestore/init";
import { getAuth } from "firebase/auth";
import { getRoomsWhereUserISAdmin } from "../../lib/firestore";
import { getStreamKey, resetRoom } from "../../lib/server-api";

const auth = getAuth(app);
/**
* AdminView renders RoomAdminUI for each room that a user id admin for
*/
interface AdminViewProps {
uid: string;
}
const Admin: React.FC<AdminViewProps> = ({ uid }) => {
const [rooms, setRooms] = useState<undefined | RoomInfo[]>(undefined);
const [state, setState] = useState<{
rooms?: RoomInfo[];
userToken?: string;
}>({});

useEffect(() => {
async function getRooms() {
let rooms = await getRoomsWhereUserISAdmin(uid);
setRooms(rooms);
let [rooms, userToken] = await Promise.all([
getRoomsWhereUserISAdmin(uid),
auth.currentUser?.getIdToken(),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to use the user from UserStore at first, but for some reason it couldn't find the getIdToken method.

]);
setState({ rooms, userToken });
}
getRooms();
}, [uid]);
}, [auth.currentUser, uid]);

return rooms ? (
return state.rooms && state.userToken ? (
<div className="stack padded border-thin lightFill">
<em>Rooms you manage</em>
<p>
Expand All @@ -28,12 +38,12 @@ const Admin: React.FC<AdminViewProps> = ({ uid }) => {
rtmps://global-live.mux.com:443/app
</span>
</p>
{rooms.map((r) => (
{state.rooms.map((r) => (
<RoomAdminUI
roomID={r.roomID}
playbackID={r.streamPlaybackID}
key={r.roomName + "-adminView"}
uid={uid}
userToken={state.userToken || ""}
/>
))}
</div>
Expand All @@ -45,8 +55,8 @@ const Admin: React.FC<AdminViewProps> = ({ uid }) => {
const RoomAdminUI: React.FC<{
roomID: string;
playbackID?: string;
uid: string;
}> = ({ roomID, playbackID, uid }) => {
userToken: string;
}> = ({ roomID, playbackID, userToken }) => {
return (
<div className="stack padded border-thin">
<div>
Expand All @@ -58,7 +68,7 @@ const RoomAdminUI: React.FC<{
</div>
<div
onClick={() => {
resetRoom(roomID);
resetRoom(userToken, roomID);
}}
className="button"
>
Expand Down
10 changes: 9 additions & 1 deletion client/src/components/room/streamGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import {
StreamVideoClient,
User,
} from "@stream-io/video-react-sdk";
import { getAuth } from "firebase/auth";

import { useCallback, useEffect, useState } from "react";

import { app } from "../../lib/firestore/init";
import { logError, logInfo } from "../../lib/logger";
import { getStreamAdminCredentials } from "../../lib/server-api";
import { useAdminStore } from "../../stores/adminStore";
import { useRoomStore } from "../../stores/roomStore";

const auth = getAuth(app);
const publicUser: User = { type: "anonymous" };

/**
Expand Down Expand Up @@ -106,7 +109,12 @@ const getClient = async (
* not worry too much about those credentials being viewable in the console.
*/
console.log("using admin credentials for stream player");
const creds = await getStreamAdminCredentials(roomId);
if (!auth.currentUser) {
throw Error("Current user is unexpectedly null");
}

const adminToken = await auth.currentUser.getIdToken();
const creds = await getStreamAdminCredentials(adminToken, roomId);
const apiKey = process.env.NEXT_PUBLIC_STREAM_API_KEY!;

return [
Expand Down
40 changes: 13 additions & 27 deletions client/src/lib/server-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ export const generateStreamLink = (playbackID: string) => {
return `https://stream.mux.com/${playbackID}.m3u8`;
};

const fetchResponse = async (endpoint: string) => {
const response = await fetch(endpoint);
const fetchResponse = async (adminToken: string, endpoint: string) => {
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${adminToken}`,
},
});
if (!response.ok) {
throw new Error(
`Request to ${endpoint} failed with status code ${response.status}`,
Expand All @@ -20,12 +24,10 @@ const fetchResponse = async (endpoint: string) => {
return response;
};

export const getStreamKey = async (streamName: string) => {
export const getStreamKey = async (adminToken: string, streamName: string) => {
try {
// Imagine that this getStreamKey has a secret key like
// let secretKey = hash(process.env.secretKey)
//Then fetchResponse(/streamName, )
const streamKeyResponse = await fetchResponse(
adminToken,
`${STREAMS_KEY_ENDPOINT}/${streamName}`,
);
const streamKeys = await streamKeyResponse.json();
Expand All @@ -42,26 +44,8 @@ interface ServerCallResponse {
rtmpStreamKey: string;
}

export const getStreamCall = async (
streamName: string,
): Promise<ServerCallResponse | undefined> => {
try {
const resp = await fetchResponse(`${STREAM_ENDPOINT}/${streamName}/call`);
const json = await resp.json();

return {
id: json["callId"],
rtmpAddress: json["rtmpAddress"],
rtmpStreamKey: json["rtmpStreamKey"],
};
} catch (e) {
console.log("Error getting stream call", (e as Error).message);
return undefined;
}
};

export const resetRoom = async (roomID: string) => {
await fetch(SERVER_URL + "/reset-room/" + roomID);
export const resetRoom = async (adminToken: string, roomID: string) => {
return fetchResponse(adminToken, SERVER_URL + "/reset-room/" + roomID);
};

interface StreamAdminCredentials {
Expand All @@ -71,9 +55,11 @@ interface StreamAdminCredentials {
}

export const getStreamAdminCredentials: (
adminToken: string,
roomID: string,
) => Promise<StreamAdminCredentials> = async (roomID) => {
) => Promise<StreamAdminCredentials> = async (adminToken, roomID) => {
const streamTokenResponse = await fetchResponse(
adminToken,
`${STREAM_ENDPOINT}/${roomID}/token`,
);
const resp = await streamTokenResponse.json();
Expand Down
1 change: 0 additions & 1 deletion client/src/pages/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { NextPage } from "next";
import { useCallback, useEffect } from "react";

import Auth from "../components/account/auth";
import Layout from "../components/room/layout";
import { useRoomStore } from "../stores/roomStore";

const Account: NextPage = () => {
Expand Down
10 changes: 7 additions & 3 deletions server/src/firebase-init.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { initializeApp, cert } from "firebase-admin/app";
import { getFirestore } from "firebase-admin/firestore";
import { cert, initializeApp } from "firebase-admin/app";

import dotenv from "dotenv";
import { existsSync } from 'fs';
import { getAuth } from "firebase-admin/auth";
import { getFirestore } from "firebase-admin/firestore";
import { resolve } from 'path';

const envLocalPath = resolve(process.cwd(), '.env.local');
Expand All @@ -23,4 +25,6 @@ export const app = initializeApp({
databaseURL: "https://is-this-thing-on-320a7-default-rtdb.firebaseio.com",
storageBucket: "is-this-thing-on-320a7.appspot.com",
});
export const firestore = getFirestore(app);

export const firestore = getFirestore(app);
export const firebaseAuth = getAuth(app);
10 changes: 8 additions & 2 deletions server/src/firestore-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DocumentData, FieldValue, QueryDocumentSnapshot, Timestamp } from "firebase-admin/firestore";
import { logError, logInfo, logUpdate, logWarning } from './logger.js';
import { logError, logInfo, logUpdate } from './logger.js';

import { FieldValue } from "firebase-admin/firestore";
import { firestore } from './firebase-init.js';

const PRESENCE_LENGTH = 5 * 1000;
Expand Down Expand Up @@ -29,6 +29,12 @@ export const getRoom = async (roomID: string) => {
}
}

export async function isAdminForAnyRoom(userID: string) {
const q = firestore.collection("rooms").where("admins", "array-contains", userID).where("hidden", "!=", true).count();
const result = await q.get();
return result.data().count > 0;
}

/**
* gets stream key from firestore, given a room name.
* @param roomID
Expand Down
58 changes: 58 additions & 0 deletions server/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { logError, logWarning } from "./logger.js";

import { RequestHandler } from "express";
import { client } from "./streamAPI.js";
import { firebaseAuth } from "./firebase-init.js";
import { isAdminForAnyRoom } from "./firestore-api.js";

/**
* Middleware to ensure that the caller has provided admin credentials.
* An admin is any user who has been assigned as an admin to any room in Firestore.
*/
export const verifyThingAdmin: RequestHandler = async (req, res, next) => {
const bearer = req.headers.authorization;

if (!bearer) {
logError("No ID token provided for request");
return res.sendStatus(403);
}

const parts = bearer.split("Bearer ");
if (parts.length <= 1) {
logError("Invalid ID token provided for request");
return res.sendStatus(403);
}

const idToken = parts[1];

try {
const decodedToken = await firebaseAuth.verifyIdToken(idToken)
const uid = decodedToken.uid;

const isAdmin = await isAdminForAnyRoom(uid);
if (isAdmin) {
return next();
} else {
logError(`User with ID ${uid} is not an admin for any rooms`);
}
} catch (err) {
logError(`Failed to verify auth token: ${err}`)
}

return res.sendStatus(403);
}

/**
* Verifies the authenticity of webhook requests from Stream.
*/
export const verifyStreamIdentity: RequestHandler = async (req, res, next) => {
const signature = req.headers["x-signature"] as string;
const valid = signature && client.verifyWebhook(JSON.stringify(req.body), signature);

if (!valid) {
logWarning("Received stream webhook with invalid signature");
return res.sendStatus(401);
}

return next();
}
9 changes: 5 additions & 4 deletions server/src/muxAPI.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import Mux, { CreateLiveStreamParams } from "@mux/mux-node";
import auth from "basic-auth";
import { Request, RequestHandler } from "express";
import { connectMuxRoomDB, getStreamKey } from "./firestore-api.js";
import { logError, logInfo } from "./logger.js";

import { RequestHandler } from "express";
import auth from "basic-auth";

const { Video } = new Mux(process.env.MUX_TOKEN_ID, process.env.MUX_TOKEN_SECRET);
import { getStreamKey, connectMuxRoomDB } from "./firestore-api.js";

export const createAndReturnStreamKey : RequestHandler = async (req, res) => {
const roomID = req.params.id;
Expand Down Expand Up @@ -64,4 +65,4 @@ export const getPlaybackIDFromMuxData = ( data: any) => {
return playbackIDs[0].id
}
return undefined
}
}
15 changes: 7 additions & 8 deletions server/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createAndReturnStreamKey, muxAuthHelper } from "./muxAPI.js";
import { createStreamAdminToken, getOrCreateStreamCall, streamUpdateWasReceived } from "./streamAPI.js";
import { createStreamAdminToken, streamUpdateWasReceived } from "./streamAPI.js";
import express, { Application } from "express";
import { presenceProcessor, resetMuxFirestoreRelationship } from "./firestore-api.js";
import { verifyStreamIdentity, verifyThingAdmin } from "./middleware.js";

import bodyParser from "body-parser"
import { createServer } from "http";
Expand All @@ -16,19 +17,18 @@ const httpServer = createServer(app);

app.use(function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization");
next();
});
app.get("/", (req, res) => {
res.send("Server?");
});
app.get("/stream-key/:id", createAndReturnStreamKey);
app.get("/stream-key/:id", verifyThingAdmin, createAndReturnStreamKey);
app.post("/mux-hook", muxAuthHelper, muxUpdateWasReceived);

app.post("/stream-hook", streamUpdateWasReceived);
app.get("/stream/:id/token", createStreamAdminToken)
app.get("stream/:id/call", getOrCreateStreamCall);
app.get("/reset-room/:id", async (req,res) => {
app.post("/stream-hook", verifyStreamIdentity, streamUpdateWasReceived);
app.get("/stream/:id/token", verifyThingAdmin, createStreamAdminToken)
app.get("/reset-room/:id", verifyThingAdmin, async (req,res) => {
logUpdate(`Resetting room ${req.params.id}`);
try {
const roomID = req.params.id;
Expand All @@ -41,7 +41,6 @@ app.get("/reset-room/:id", async (req,res) => {
}
})


httpServer.listen(port, () => {
logUpdate(`Server is LIVE on port ${port}`);
});
Expand Down
22 changes: 2 additions & 20 deletions server/src/streamAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { randomUUID } from "crypto";

const apiKey = process.env.STREAM_API_KEY!;
const apiSecret = process.env.STREAM_API_SECRET!;
const client = new StreamClient(apiKey, apiSecret);
export const client = new StreamClient(apiKey, apiSecret);

const adminUserId = process.env.STREAM_ADMIN_USER_ID!;

Expand Down Expand Up @@ -65,7 +65,7 @@ export const streamUpdateWasReceived: RequestHandler = async (req, res) => {
return res.status(200).send("Thanks for the update :) ");
}
}
if (eventType == "call_ended" || eventType == "call.session_ended") {
if (eventType == "call.ended" || eventType == "call.session_ended") {
// NOTE: There is no live_ended status, so these events are the best proxies for when the streamer is offline.
//
// - call.session_ended: Occurs when participant leaves the call or closes their tab.
Expand Down Expand Up @@ -119,24 +119,6 @@ export const createStreamAdminToken: RequestHandler = async (req, res) => {
}
};

export const getOrCreateStreamCall: RequestHandler = async (req, res) => {
const roomId = req.params.id;

if (!roomId) {
res.status(400).send("No room ID provided");
return;
}

try {
const callId = getOrCreateCall(roomId);
res.send({ callId: callId });
} catch (e) {
logError(getErrorMessage(e));
res.status(500).send("Error creating Stream call");
return;
}
};

const getOrCreateCall = async (roomId: string) => {
const roomData = await getRoom(roomId);
let callId = roomData?.["stream_playback_id"];
Expand Down